switchroom 0.15.26 → 0.15.27

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.
@@ -227,6 +227,57 @@
227
227
  font-size: 0.85rem;
228
228
  }
229
229
 
230
+ /* ProblemDetail block — an actionable error/degraded state with a
231
+ remediation. Reuses the error-banner palette but in a muted card so it
232
+ reads as "here is what to do", not just "something is broken". */
233
+ .problem {
234
+ background: rgba(248,113,113,0.08);
235
+ border: 1px solid rgba(248,113,113,0.28);
236
+ border-radius: var(--radius);
237
+ padding: 0.75rem 1rem;
238
+ margin-bottom: 1rem;
239
+ font-size: 0.85rem;
240
+ }
241
+ .problem.muted {
242
+ background: var(--surface-hover);
243
+ border-color: var(--border);
244
+ }
245
+ .problem-title { font-weight: 600; color: var(--text); }
246
+ .problem.muted .problem-title { color: var(--text); }
247
+ .problem:not(.muted) .problem-title { color: var(--red); }
248
+ .problem-detail { color: var(--text-dim); margin-top: 0.25rem; }
249
+ .problem-remediation { margin-top: 0.55rem; }
250
+ .problem-remediation .rem-label { color: var(--text-dim); display: block; margin-bottom: 0.3rem; }
251
+ .problem-cmd {
252
+ display: inline-flex;
253
+ align-items: center;
254
+ gap: 0.5rem;
255
+ flex-wrap: wrap;
256
+ }
257
+ .problem-cmd code {
258
+ background: rgba(0,0,0,0.35);
259
+ border: 1px solid var(--border);
260
+ border-radius: 6px;
261
+ padding: 0.3rem 0.5rem;
262
+ font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
263
+ font-size: 0.75rem;
264
+ color: var(--text);
265
+ user-select: all;
266
+ word-break: break-all;
267
+ }
268
+ .problem-copy {
269
+ padding: 0.25rem 0.6rem;
270
+ border: 1px solid var(--border);
271
+ border-radius: 6px;
272
+ background: transparent;
273
+ color: var(--blue);
274
+ font-size: 0.72rem;
275
+ cursor: pointer;
276
+ flex-shrink: 0;
277
+ }
278
+ .problem-copy:hover { background: var(--surface-hover); }
279
+ .problem-remediation a { color: var(--blue); }
280
+
230
281
  .loading {
231
282
  text-align: center;
232
283
  padding: 4rem;
@@ -507,15 +558,30 @@
507
558
 
508
559
  async function fetchAgentDetail(name) {
509
560
  try {
510
- const [accRes, subRes, cfgRes] = await Promise.all([
561
+ const [accRes, subRes, cfgRes, turnsRes] = await Promise.all([
511
562
  fetch(`${API}/api/agents/${encodeURIComponent(name)}/accounts`, { headers: authHeaders() }),
512
563
  fetch(`${API}/api/agents/${encodeURIComponent(name)}/subagents`, { headers: authHeaders() }),
513
564
  fetch(`${API}/api/agents/${encodeURIComponent(name)}/config`, { headers: authHeaders() }),
565
+ // Recent turns — resilient: a failure leaves turns null and the
566
+ // other sections still render (see renderAgentDetailInner).
567
+ fetch(`${API}/api/agents/${encodeURIComponent(name)}/turns?limit=10`, { headers: authHeaders() }),
514
568
  ]);
569
+ // The /turns success response is a BARE Turn[] array (same
570
+ // convention as /accounts + /subagents above); only the error
571
+ // path sends { ok:false, error }. So read the array directly —
572
+ // null on a non-ok response / malformed payload.
573
+ let turns = null;
574
+ if (turnsRes.ok) {
575
+ try {
576
+ const body = await turnsRes.json();
577
+ turns = Array.isArray(body) ? body : null;
578
+ } catch { turns = null; }
579
+ }
515
580
  agentDetails[name] = {
516
581
  accounts: accRes.ok ? await accRes.json() : null,
517
582
  subagents: subRes.ok ? await subRes.json() : null,
518
583
  config: cfgRes.ok ? await cfgRes.json() : null,
584
+ turns,
519
585
  };
520
586
  render();
521
587
  } catch (err) {
@@ -548,11 +614,7 @@
548
614
  function renderMemoryHealth(m) {
549
615
  const container = document.getElementById('memory');
550
616
  if (!m.reachable) {
551
- container.innerHTML = `<div class="agent-card" style="padding:1rem">
552
- <span class="status-dot inactive" style="display:inline-block;vertical-align:middle"></span>
553
- <strong> Hindsight unreachable</strong>
554
- <div style="color:var(--text-dim);margin-top:.5rem">${escapeHtml(m.url || '')} is not serving — agent memory (recall, retain, mental models) is down.</div>
555
- </div>`;
617
+ container.innerHTML = renderProblem(problemFor('hindsight-down', { url: m.url }));
556
618
  return;
557
619
  }
558
620
  const statusDot = (s) => `<span class="status-dot ${s === 'ok' ? 'active' : s === 'warn' ? 'auth-warning' : 'inactive'}" style="display:inline-block;vertical-align:middle"></span>`;
@@ -563,7 +625,16 @@
563
625
  if (isNaN(d)) return '';
564
626
  return d < 1 ? 'today' : `${Math.round(d)}d ago`;
565
627
  };
628
+ // A bank "has a user-profile model" if any mental model is named
629
+ // like user-profile (mirrors hindsight's exact-name check, but
630
+ // tolerant of the "User Profile" display variant for the UI hint).
631
+ const hasUserProfile = (b) => (b.mentalModels || []).some(mm =>
632
+ /^user[- ]?profile$/i.test(String(mm.name || '')));
633
+ // JSON for a single-quoted onclick attribute: escape the quote
634
+ // chars that could break out of the attribute (', &, <, >).
635
+ const attrJson = (v) => escapeHtml(JSON.stringify(v));
566
636
  const cards = (m.banks || []).map(b => {
637
+ const bankJs = attrJson(b.bank);
567
638
  const models = (b.mentalModels || []).map(mm => {
568
639
  const ts = mm.lastRefreshedAt || mm.createdAt;
569
640
  const stale = ts && (Date.now() - Date.parse(ts)) > 7 * 86400000;
@@ -575,6 +646,38 @@
575
646
  const corruptLine = (b.corruptedMentalModelNames || []).length > 0
576
647
  ? `<div style="color:var(--red);margin-top:.4rem">⚠ corrupted mental model(s): ${escapeHtml(b.corruptedMentalModelNames.join(', '))} — content is an LLM failure message; refresh once quota recovers</div>`
577
648
  : '';
649
+ // --- Remediation buttons (audit ranks 7 + 22) ---
650
+ // Each triggers an HTTP poke at hindsight; hindsight does the LLM
651
+ // work on its own provider — the dashboard makes NO model call.
652
+ const buttons = [];
653
+ // Reprocess the extraction-gap docs.
654
+ if (b.recentUnextractedCount > 0) {
655
+ const n = Math.min(b.recentUnextractedCount, (b.unextractedDocIds || []).length) || b.recentUnextractedCount;
656
+ buttons.push(`<button class="btn" type="button" onclick='memReprocess(${bankJs}, this)'>Reprocess ${n} doc${n === 1 ? '' : 's'}</button>`);
657
+ }
658
+ // Refresh each corrupted/stale model (map corrupted NAME → id).
659
+ const affectedIds = new Set();
660
+ for (const name of (b.corruptedMentalModelNames || [])) {
661
+ const mm = (b.mentalModels || []).find(x => x.name === name);
662
+ if (mm && mm.id) affectedIds.add(mm.id);
663
+ }
664
+ for (const mm of (b.mentalModels || [])) {
665
+ const ts = mm.lastRefreshedAt || mm.createdAt;
666
+ const stale = ts && (Date.now() - Date.parse(ts)) > 7 * 86400000;
667
+ if (stale && mm.id) affectedIds.add(mm.id);
668
+ }
669
+ for (const mm of (b.mentalModels || [])) {
670
+ if (!affectedIds.has(mm.id)) continue;
671
+ const idJs = attrJson(mm.id), nameJs = attrJson(mm.name);
672
+ buttons.push(`<button class="btn" type="button" onclick='memRefreshModel(${bankJs}, ${idJs}, ${nameJs}, this)'>Refresh ${escapeHtml(mm.name)}</button>`);
673
+ }
674
+ // Build the user-profile model when the bank has data but no profile.
675
+ if (b.totalDocuments > 0 && !hasUserProfile(b)) {
676
+ buttons.push(`<button class="btn" type="button" onclick='memBuildProfile(${bankJs}, this)'>Build profile</button>`);
677
+ }
678
+ const buttonRow = buttons.length
679
+ ? `<div class="rem-actions" style="margin-top:.6rem;display:flex;flex-wrap:wrap;gap:.4rem">${buttons.join('')}</div>`
680
+ : '';
578
681
  return `<div class="agent-card">
579
682
  <div class="card-header" style="cursor:default">
580
683
  ${statusDot(b.status)}<span class="agent-name">${escapeHtml(b.bank)}</span>
@@ -591,12 +694,59 @@
591
694
  ${models ? `<div class="card-meta" style="padding:.4rem 0 0">${models}</div>` : ''}
592
695
  ${corruptLine}
593
696
  ${gapLine}
697
+ ${buttonRow}
594
698
  </div>
595
699
  </div>`;
596
700
  }).join('');
597
701
  container.innerHTML = `<div class="agents-grid">${cards || '<div style="color:var(--text-dim)">No agent banks configured.</div>'}</div>`;
598
702
  }
599
703
 
704
+ // --- Memory remediation actions ---
705
+ // Each pokes a hindsight REST/MCP endpoint via the web's POST routes.
706
+ // Hindsight does the LLM extraction on its OWN claude-code provider;
707
+ // the dashboard never calls a model. A confirm() gates each because
708
+ // it consumes subscription quota (low-risk, but explicit).
709
+ const REM_CONFIRM = 'This triggers memory re-processing on the subscription. Continue?';
710
+
711
+ // `label` names the action for the failure toast; `successMsg(data)`
712
+ // builds the success toast from the response body.
713
+ async function memRemediate(btn, url, payload, working, label, successMsg) {
714
+ if (!confirm(REM_CONFIRM)) return;
715
+ const orig = btn ? btn.textContent : '';
716
+ if (btn) { btn.disabled = true; btn.textContent = working; }
717
+ try {
718
+ const res = await fetch(`${API}${url}`, {
719
+ method: 'POST',
720
+ headers: { ...authHeaders(), 'Content-Type': 'application/json' },
721
+ body: JSON.stringify(payload),
722
+ });
723
+ const data = await res.json().catch(() => ({}));
724
+ if (!res.ok || !data.ok) {
725
+ showToast(`${label} failed: ${data.error || `HTTP ${res.status}`}`, false);
726
+ } else {
727
+ showToast(successMsg(data), true);
728
+ }
729
+ } catch (err) {
730
+ showToast(`${label} failed: ${err.message}`, false);
731
+ } finally {
732
+ if (btn) { btn.disabled = false; btn.textContent = orig; }
733
+ fetchMemoryHealth();
734
+ }
735
+ }
736
+
737
+ function memReprocess(bank, btn) {
738
+ memRemediate(btn, '/api/memory/reprocess', { bank }, 'Reprocessing…', 'Reprocess',
739
+ (d) => `Reprocess triggered (${d.triggered ?? 0} doc${(d.triggered ?? 0) === 1 ? '' : 's'}${d.failed ? `, ${d.failed} failed` : ''})`);
740
+ }
741
+ function memRefreshModel(bank, modelId, name, btn) {
742
+ memRemediate(btn, '/api/memory/refresh-model', { bank, modelId }, 'Refreshing…', 'Refresh',
743
+ () => `Refresh triggered for ${name}`);
744
+ }
745
+ function memBuildProfile(bank, btn) {
746
+ memRemediate(btn, '/api/memory/build-profile', { bank }, 'Building…', 'Build profile',
747
+ () => 'Profile build triggered');
748
+ }
749
+
600
750
  async function fetchConnections() {
601
751
  // Each fetch falls back independently (.catch → default). A single
602
752
  // network blip — e.g. one endpoint momentarily unreachable — must NOT
@@ -783,10 +933,21 @@
783
933
  }
784
934
 
785
935
  async function fetchApprovals() {
936
+ // Fetch the kernel decision ledger AND the standing capability grants
937
+ // in parallel, INDEPENDENTLY: a failure of one must not blank the
938
+ // other (mirror the resilient `safe` pattern in fetchConnections).
939
+ // Standing grants (the broker grants DB) are the section that's
940
+ // actually useful — the kernel ledger is honestly empty on most
941
+ // installs.
942
+ const safe = (p, fallback) =>
943
+ p.then(r => r.ok ? r.json() : fallback).catch(() => fallback);
786
944
  try {
787
- const res = await fetch(`${API}/api/approvals`, { headers: authHeaders() });
788
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
789
- renderApprovals(await res.json());
945
+ const [approvals, grants] = await Promise.all([
946
+ safe(fetch(`${API}/api/approvals`, { headers: authHeaders() }), null),
947
+ safe(fetch(`${API}/api/grants`, { headers: authHeaders() }),
948
+ { reachable: false, grants: [], error: 'Failed to fetch standing grants.' }),
949
+ ]);
950
+ renderApprovals(approvals, grants);
790
951
  clearError();
791
952
  } catch (err) {
792
953
  showError(`Failed to fetch approvals: ${err.message}`);
@@ -808,22 +969,29 @@
808
969
  if (tab === 'approvals') fetchApprovals();
809
970
  }
810
971
 
811
- // Fleet overview — aggregates the cheap endpoints client-side
812
- // (one parallel fan-out; no new server work, no extra docker/probe
813
- // cost beyond what those tabs already do). Each tile degrades
814
- // independently if its source errors.
972
+ // Fleet overview — ONE round-trip to the server-side aggregate
973
+ // (/api/summary), which pulls each part through the same per-tab
974
+ // cache so a Summary open warms the tabs and they can never disagree.
975
+ // Each tile still degrades independently: the server nulls any part
976
+ // whose producer threw, and a whole-fetch failure leaves the prior
977
+ // render in place.
815
978
  async function fetchSummary() {
816
979
  const el = document.getElementById('summary');
817
- const get = async (p) => {
818
- try {
819
- const r = await fetch(`${API}${p}`, { headers: authHeaders() });
820
- return r.ok ? await r.json() : null;
821
- } catch { return null; }
822
- };
823
- const [ag, sys, appr, sched, accts] = await Promise.all([
824
- get('/api/agents'), get('/api/system-health'),
825
- get('/api/approvals'), get('/api/schedule'), get('/api/accounts'),
826
- ]);
980
+ let summary;
981
+ try {
982
+ const r = await fetch(`${API}/api/summary`, { headers: authHeaders() });
983
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
984
+ summary = await r.json();
985
+ } catch (err) {
986
+ // Degraded: keep whatever's already rendered (don't blank the
987
+ // tab on a transient blip) and surface the error.
988
+ el.classList.remove('loading');
989
+ showError(`Failed to fetch summary: ${err.message}`);
990
+ return;
991
+ }
992
+ const ag = summary.agents, sys = summary.systemHealth,
993
+ appr = summary.approvals, sched = summary.schedule,
994
+ accts = summary.accounts;
827
995
  clearError();
828
996
  const tile = (title, body, ok) => `
829
997
  <div class="agent-card" style="min-width:220px">
@@ -899,7 +1067,37 @@
899
1067
  } else quotaTile = tile('Quota', dim('unavailable'), null);
900
1068
 
901
1069
  el.classList.remove('loading');
902
- el.innerHTML = `<div class="agents-grid">${agentsTile}${brokerTile}${hsTile}${hostdTile}${apprTile}${schedTile}${quotaTile}</div>`;
1070
+ // "data as of" — the summary's freshness is its OLDEST part (server
1071
+ // sends the min dataAsOf). A tiny muted line; refreshed on every
1072
+ // tab-open. `formatTimestamp` renders it as "Ns/Nm ago".
1073
+ const asOf = (typeof summary.dataAsOf === 'number' && summary.dataAsOf > 0)
1074
+ ? `<div style="color:var(--text-dim);font-size:.75rem;margin:.25rem 0 .5rem">data as of ${escapeHtml(formatTimestamp(summary.dataAsOf))}</div>`
1075
+ : '';
1076
+ el.innerHTML = `${renderAttention(summary.attention)}${asOf}<div class="agents-grid">${agentsTile}${brokerTile}${hsTile}${hostdTile}${apprTile}${schedTile}${quotaTile}</div>`;
1077
+ }
1078
+
1079
+ // "Needs attention" triage strip — the FIRST block on the Summary tab.
1080
+ // The server derives `attention: AttentionItem[]` (severity-sorted,
1081
+ // capped) from the parts the summary already carries; each item is
1082
+ // clickable → switchTab(item.tab). Reuses the .problem styling
1083
+ // (red title for critical/warn, muted for info). Empty → a calm
1084
+ // "nothing needs attention" line. Every interpolated value is escaped.
1085
+ function renderAttention(items) {
1086
+ if (!Array.isArray(items) || items.length === 0) {
1087
+ return `<div class="problem muted" style="margin-bottom:.75rem"><div class="problem-title">✓ Nothing needs attention</div></div>`;
1088
+ }
1089
+ const rows = items.map((it) => {
1090
+ const sev = (it && it.severity) || 'info';
1091
+ const muted = sev === 'info' ? ' muted' : '';
1092
+ const tab = String((it && it.tab) || 'summary');
1093
+ const title = `<div class="problem-title">${escapeHtml((it && it.title) || 'Attention')}</div>`;
1094
+ const detail = it && it.detail
1095
+ ? `<div class="problem-detail">${escapeHtml(it.detail)}</div>`
1096
+ : '';
1097
+ // Whole row clickable → jump to the tab to act on it.
1098
+ return `<div class="problem${muted}" role="button" tabindex="0" style="cursor:pointer;margin-bottom:.4rem" onclick="switchTab('${escapeHtml(tab)}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();switchTab('${escapeHtml(tab)}')}">${title}${detail}</div>`;
1099
+ }).join('');
1100
+ return `<div style="margin-bottom:.75rem">${rows}</div>`;
903
1101
  }
904
1102
 
905
1103
  function showError(msg) {
@@ -922,6 +1120,206 @@
922
1120
  }[c]));
923
1121
  }
924
1122
 
1123
+ // ───────────────────────────────────────────────────────────────────
1124
+ // ProblemDetail — actionable error/degraded rendering.
1125
+ //
1126
+ // A ProblemDetail is `{ title, detail?, remediation? }` where
1127
+ // `remediation = { kind, label, value }` and kind ∈
1128
+ // 'command' | 'link' | 'telegram' | 'none'. renderProblem() turns it into
1129
+ // a styled block; problemFor() is a small catalog mapping a KNOWN
1130
+ // condition → a ProblemDetail (with the next-step command baked in).
1131
+ //
1132
+ // XSS: every server-derived string (title/detail/error text) goes through
1133
+ // escapeHtml. Remediation `value` for 'command' is always a static
1134
+ // literal from the catalog, but it's escaped anyway for defence in depth.
1135
+ // ───────────────────────────────────────────────────────────────────
1136
+
1137
+ // Copy a command to the clipboard. Degrades gracefully where the
1138
+ // Clipboard API is unavailable (insecure context / old browser): the
1139
+ // <code> is `user-select:all` so the operator can still copy by hand.
1140
+ function copyProblemCmd(btn) {
1141
+ const text = btn && btn.getAttribute('data-copy');
1142
+ if (text == null) return;
1143
+ const flash = (label) => {
1144
+ const prev = btn.textContent;
1145
+ btn.textContent = label;
1146
+ setTimeout(() => { btn.textContent = prev; }, 1200);
1147
+ };
1148
+ try {
1149
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1150
+ navigator.clipboard.writeText(text).then(() => flash('Copied'), () => flash('Copy failed'));
1151
+ } else {
1152
+ flash('Select & copy');
1153
+ }
1154
+ } catch {
1155
+ flash('Select & copy');
1156
+ }
1157
+ }
1158
+
1159
+ // Allowlist link schemes for remediation hrefs. escapeHtml prevents
1160
+ // attribute breakout but NOT a `javascript:`/`data:` scheme; today no
1161
+ // catalog entry uses a server-derived href, but guard it so a future
1162
+ // `link`/`telegram` remediation can never become an injection vector.
1163
+ function safeHref(url) {
1164
+ const u = String(url || '').trim();
1165
+ return /^(https?:|tg:|mailto:)/i.test(u) ? u : '#';
1166
+ }
1167
+
1168
+ function renderProblem(problem) {
1169
+ if (!problem) return '';
1170
+ const muted = problem.muted ? ' muted' : '';
1171
+ const title = `<div class="problem-title">${escapeHtml(problem.title || 'Problem')}</div>`;
1172
+ const detail = problem.detail
1173
+ ? `<div class="problem-detail">${escapeHtml(problem.detail)}</div>`
1174
+ : '';
1175
+ let rem = '';
1176
+ const r = problem.remediation;
1177
+ if (r && r.kind && r.kind !== 'none') {
1178
+ if (r.kind === 'command') {
1179
+ const cmd = escapeHtml(r.value || '');
1180
+ // data-copy holds the RAW (unescaped) command for clipboard write;
1181
+ // attribute context is still escaped via escapeHtml so the value
1182
+ // can't break out of the attribute.
1183
+ rem = `<div class="problem-remediation">
1184
+ ${r.label ? `<span class="rem-label">${escapeHtml(r.label)}</span>` : ''}
1185
+ <span class="problem-cmd"><code>${cmd}</code><button class="problem-copy" type="button" data-copy="${escapeHtml(r.value || '')}" onclick="copyProblemCmd(this)">Copy</button></span>
1186
+ </div>`;
1187
+ } else if (r.kind === 'link') {
1188
+ rem = `<div class="problem-remediation">
1189
+ <a href="${escapeHtml(safeHref(r.value))}" target="_blank" rel="noopener">${escapeHtml(r.label || r.value || 'Open')}</a>
1190
+ </div>`;
1191
+ } else if (r.kind === 'telegram') {
1192
+ const note = r.value
1193
+ ? `<a href="${escapeHtml(safeHref(r.value))}" target="_blank" rel="noopener">${escapeHtml(r.label || 'Act in Telegram')}</a>`
1194
+ : escapeHtml(r.label || 'Act from Telegram (no model call is made from the dashboard).');
1195
+ rem = `<div class="problem-remediation">${note}</div>`;
1196
+ }
1197
+ } else if (r && r.label) {
1198
+ // kind:'none' but a label hint was supplied (e.g. a "Refresh" nudge).
1199
+ rem = `<div class="problem-remediation"><span class="rem-label">${escapeHtml(r.label)}</span></div>`;
1200
+ }
1201
+ return `<div class="problem${muted}">${title}${detail}${rem}</div>`;
1202
+ }
1203
+
1204
+ // Catalog: known condition → ProblemDetail. Keep small + readable. Error
1205
+ // strings are pattern-matched defensively (case-insensitive, optional).
1206
+ function problemFor(kind, ctx) {
1207
+ ctx = ctx || {};
1208
+ switch (kind) {
1209
+ case 'hindsight-down':
1210
+ return {
1211
+ title: 'Hindsight (agent memory) is not serving',
1212
+ detail: (ctx.url ? `${ctx.url} is not responding. ` : '') +
1213
+ 'Agent memory — recall, retain, mental models — is down until it recovers.',
1214
+ remediation: {
1215
+ kind: 'command',
1216
+ label: 'Run on the host:',
1217
+ value: 'switchroom memory setup --stop && switchroom memory setup',
1218
+ },
1219
+ };
1220
+ case 'broker-socket-absent':
1221
+ return {
1222
+ title: 'Auth-broker socket not mounted into the dashboard',
1223
+ detail: (ctx.error ? `${ctx.error} ` : '') +
1224
+ 'The web container has no broker socket — re-mount it so the dashboard can read live account state.',
1225
+ remediation: {
1226
+ kind: 'command',
1227
+ label: 'Re-mount on the host:',
1228
+ value: 'switchroom web install',
1229
+ },
1230
+ };
1231
+ case 'broker-down':
1232
+ return {
1233
+ title: 'Auth-broker unreachable',
1234
+ detail: (ctx.error ? `${ctx.error} ` : '') +
1235
+ 'The socket is present but the broker is not answering.',
1236
+ remediation: {
1237
+ kind: 'command',
1238
+ label: 'Check broker status on the host:',
1239
+ value: 'switchroom vault broker status',
1240
+ },
1241
+ };
1242
+ case 'hostd-no-audit':
1243
+ return {
1244
+ muted: true,
1245
+ title: 'hostd has handled no privileged verbs yet',
1246
+ detail: 'This is informational — the audit log is empty because no privileged operation has run, not because hostd is broken.',
1247
+ remediation: { kind: 'none' },
1248
+ };
1249
+ case 'schedule-degraded':
1250
+ return {
1251
+ title: 'Schedule view is incomplete',
1252
+ detail: ctx.reason
1253
+ ? `${ctx.reason} Showing the base-config-only view (per-agent cascade overrides may be missing).`
1254
+ : 'hostd is unreachable — showing the base-config-only view (per-agent cascade overrides may be missing).',
1255
+ remediation: {
1256
+ kind: 'command',
1257
+ label: 'Diagnose on the host:',
1258
+ value: 'switchroom doctor',
1259
+ },
1260
+ };
1261
+ case 'approvals-socket-absent':
1262
+ return {
1263
+ title: 'Approval-kernel operator socket not present',
1264
+ detail: (ctx.error ? `${ctx.error} ` : '') +
1265
+ 'The dashboard has no operator socket to read the approval ledger — re-apply to (re)create it.',
1266
+ remediation: {
1267
+ kind: 'command',
1268
+ label: 'Run on the host:',
1269
+ value: 'switchroom apply',
1270
+ },
1271
+ };
1272
+ case 'approvals-kernel-down':
1273
+ return {
1274
+ title: 'Approval-kernel unreachable',
1275
+ detail: (ctx.error ? `${ctx.error} ` : '') +
1276
+ 'The kernel socket is present but the kernel is not answering.',
1277
+ remediation: {
1278
+ kind: 'command',
1279
+ label: 'Check the singletons on the host:',
1280
+ value: 'docker compose -p switchroom ps',
1281
+ },
1282
+ };
1283
+ case 'grants-unreachable':
1284
+ return {
1285
+ title: 'Standing capability grants unavailable',
1286
+ detail: (ctx.error ? `${ctx.error} ` : '') +
1287
+ 'The vault-broker (which owns the grants DB) is not host-reachable, so the operator\'s standing grants can\'t be listed.',
1288
+ remediation: {
1289
+ kind: 'command',
1290
+ label: 'Check broker status on the host:',
1291
+ value: 'switchroom vault broker status',
1292
+ },
1293
+ };
1294
+ case 'connections-degraded':
1295
+ return {
1296
+ muted: true,
1297
+ title: 'Live broker data unavailable — showing configured accounts only',
1298
+ detail: 'These accounts are from the config; the broker did not return live slot state, so connection status may be stale.',
1299
+ remediation: { kind: 'none', label: 'Refresh the tab once the broker is reachable to see live status.' },
1300
+ };
1301
+ default:
1302
+ return { title: String(kind || 'Problem'), remediation: { kind: 'none' } };
1303
+ }
1304
+ }
1305
+
1306
+ // Classify an auth-broker error string into the right catalog condition.
1307
+ // "socket file not found" / ENOENT / "no such file" ⇒ socket not mounted;
1308
+ // anything else (connection refused, etc.) ⇒ broker is down.
1309
+ function brokerProblem(error) {
1310
+ return /not found|enoent|no such file/i.test(String(error || ''))
1311
+ ? problemFor('broker-socket-absent', { error })
1312
+ : problemFor('broker-down', { error });
1313
+ }
1314
+
1315
+ // Classify an approvals error string. "operator socket not present" /
1316
+ // operatorUid ⇒ socket absent (apply); otherwise kernel is down.
1317
+ function approvalsProblem(error) {
1318
+ return /not present|operatoruid/i.test(String(error || ''))
1319
+ ? problemFor('approvals-socket-absent', { error })
1320
+ : problemFor('approvals-kernel-down', { error });
1321
+ }
1322
+
925
1323
  function statusClass(agent) {
926
1324
  if (agent.active === 'active') {
927
1325
  if (agent.auth && agent.auth.expiresAt) {
@@ -958,7 +1356,7 @@
958
1356
  container.innerHTML = agents.map(a => `
959
1357
  <div class="agent-card" data-agent="${escapeHtml(a.name)}">
960
1358
  <div class="card-header" onclick="toggleLogs('${escapeHtml(a.name)}')">
961
- <div class="status-dot ${statusClass(a)}" title="${escapeHtml(a.active)}"></div>
1359
+ <div class="status-dot ${statusClass(a)}" title="${escapeHtml(a.active)} · bridge heartbeat (not a claude-liveness signal)"></div>
962
1360
  <div class="agent-name">${escapeHtml(a.name)}</div>
963
1361
  <div class="agent-topic">${a.topic_emoji ? escapeHtml(a.topic_emoji) + ' ' : ''}${escapeHtml(a.topic_name)}</div>
964
1362
  </div>
@@ -966,6 +1364,7 @@
966
1364
  <div class="meta-item"><label>Status </label><span>${escapeHtml(a.active)}</span></div>
967
1365
  <div class="meta-item"><label>Uptime </label><span>${formatUptime(a.uptime)}</span></div>
968
1366
  <div class="meta-item"><label>Mem </label><span>${a.memory || '--'}</span></div>
1367
+ <div class="meta-item"><label>Last turn </label><span>${a.lastTurnAt ? formatTimestamp(a.lastTurnAt) : '—'}</span></div>
969
1368
  <div class="meta-item"><label>Profile </label><span>${escapeHtml(a.extends)}</span></div>
970
1369
  <div class="meta-item"><label>Auth </label><span>${a.auth.authenticated ? '✓' : '✗'}</span></div>
971
1370
  <div class="meta-item"><label>Account </label><span>${a.primaryAccount ? escapeHtml(a.primaryAccount) : '<span style="color:var(--text-dim)">default</span>'}</span></div>
@@ -992,23 +1391,39 @@
992
1391
  }
993
1392
 
994
1393
  function renderAgentDetail(name) {
1394
+ // Wrapped so a single malformed detail can NEVER throw into render()'s
1395
+ // agents.map and blank the whole grid. (The accounts.assigned crash this
1396
+ // replaces did exactly that: it read a field the API renamed to
1397
+ // {active, details} post-RFC-H, threw a TypeError on every open panel,
1398
+ // and aborted the map → the entire Agents tab went blank on each poll.)
1399
+ try {
1400
+ return renderAgentDetailInner(name);
1401
+ } catch (err) {
1402
+ return `<div style="color:var(--red)">Detail render failed: ${escapeHtml((err && err.message) || String(err))}</div>`;
1403
+ }
1404
+ }
1405
+
1406
+ function renderAgentDetailInner(name) {
995
1407
  const d = agentDetails[name];
996
1408
  if (!d) return '<div style="color:var(--text-dim)">Loading…</div>';
997
1409
  const accounts = d.accounts;
998
1410
  const subagents = d.subagents;
999
1411
  const config = d.config;
1000
1412
 
1413
+ // handleGetAgentAccounts returns { active: string|null, details: AccountInfo[] }
1414
+ // — the single bound account (fleet-active or per-agent auth.override),
1415
+ // NOT the pre-RFC-H assigned[] list.
1001
1416
  let accountsHtml;
1002
- if (!accounts || accounts.assigned.length === 0) {
1003
- accountsHtml = '<div style="color:var(--text-dim)">No accounts assigned (falls back to <code>default</code>).</div>';
1417
+ const activeLabel = accounts && accounts.active;
1418
+ if (!activeLabel) {
1419
+ accountsHtml = '<div style="color:var(--text-dim)">No per-agent account override — uses the fleet-active account.</div>';
1004
1420
  } else {
1005
- const byLabel = new Map((accounts.details || []).map(d => [d.label, d]));
1006
- accountsHtml = accounts.assigned.map((label, i) => {
1007
- const info = byLabel.get(label);
1008
- if (!info) return `<span class="chip missing" title="not in ~/.switchroom/accounts/">${escapeHtml(label)} · missing</span>`;
1009
- const tag = i === 0 ? ' · primary' : '';
1010
- return `<span class="chip"><span class="health-badge ${escapeHtml(info.health)}" style="margin-right:0.4rem">${escapeHtml(info.health)}</span>${escapeHtml(label)}${tag}</span>`;
1011
- }).join('');
1421
+ const info = (accounts.details || []).find(x => x && x.label === activeLabel);
1422
+ if (!info) {
1423
+ accountsHtml = `<span class="chip missing" title="bound account not found in ~/.switchroom/accounts/">${escapeHtml(activeLabel)} · missing</span>`;
1424
+ } else {
1425
+ accountsHtml = `<span class="chip"><span class="health-badge ${escapeHtml(info.health)}" style="margin-right:0.4rem">${escapeHtml(info.health)}</span>${escapeHtml(activeLabel)} · bound</span>`;
1426
+ }
1012
1427
  }
1013
1428
 
1014
1429
  let subHtml;
@@ -1018,11 +1433,37 @@
1018
1433
  subHtml = subagents.map(s => `<span class="chip">${escapeHtml(s.name || s.id || '?')}${s.status ? ' · ' + escapeHtml(s.status) : ''}</span>`).join('');
1019
1434
  }
1020
1435
 
1436
+ // Recent turns — each Turn carries `started_at` (unix ms),
1437
+ // `ended_at` (null while in-flight), and `user_prompt_preview` (the
1438
+ // first ~200 chars of the user message). We render the start time,
1439
+ // an ended/in-flight status, and a truncated prompt. A null `turns`
1440
+ // (fetch failed) renders nothing different from "no turns" — the
1441
+ // other sections are unaffected either way.
1442
+ const turns = d.turns;
1443
+ let turnsHtml;
1444
+ if (!Array.isArray(turns) || turns.length === 0) {
1445
+ turnsHtml = '<div style="color:var(--text-dim)">No turns recorded.</div>';
1446
+ } else {
1447
+ turnsHtml = turns.map(t => {
1448
+ const when = escapeHtml(formatTimestamp(t.started_at));
1449
+ const status = t.ended_at ? 'ended' : 'in-flight';
1450
+ const raw = (t.user_prompt_preview || '').replace(/\s+/g, ' ').trim();
1451
+ const summary = raw
1452
+ ? escapeHtml(raw.length > 80 ? raw.slice(0, 80) + '…' : raw)
1453
+ : '<span style="color:var(--text-dim)">(no prompt)</span>';
1454
+ return `<div style="margin-bottom:.4rem">
1455
+ <span style="color:var(--text-dim);font-size:.8rem">${when} · ${escapeHtml(status)}</span><br>
1456
+ <span>${summary}</span>
1457
+ </div>`;
1458
+ }).join('');
1459
+ }
1460
+
1021
1461
  const configText = config ? JSON.stringify(config, null, 2) : '(unavailable)';
1022
1462
 
1023
1463
  return `
1024
- <h4>Accounts</h4>${accountsHtml}
1464
+ <h4>Account</h4>${accountsHtml}
1025
1465
  <h4>Sub-agents</h4>${subHtml}
1466
+ <h4>Recent turns</h4>${turnsHtml}
1026
1467
  <h4>Resolved config</h4><pre class="config-pre">${escapeHtml(configText)}</pre>
1027
1468
  `;
1028
1469
  }
@@ -1071,7 +1512,7 @@
1071
1512
  fiveH: pctCell(u ? u.fiveHourPct : null, '5h', a.quotaStale, u ? u.fiveHourResetAt : null),
1072
1513
  sevenD: pctCell(u ? u.sevenDayPct : null, '7d', a.quotaStale, u ? u.sevenDayResetAt : null),
1073
1514
  captured: u && u.capturedAt
1074
- ? formatTimestamp(u.capturedAt)
1515
+ ? `${formatTimestamp(u.capturedAt)}${a.quotaUsageSource === 'broker-cache' ? '<div style="font-size:.7rem;color:var(--text-dim)">broker cache (free)</div>' : ''}`
1075
1516
  : '<span style="color:var(--text-dim)">never</span>',
1076
1517
  exhaustedCell: q == null
1077
1518
  ? '<span style="color:var(--text-dim)">broker offline</span>'
@@ -1202,41 +1643,74 @@
1202
1643
  <div class="meta-item"><label>Agents </label><span>${b.agents ?? '—'}</span></div>
1203
1644
  <div class="meta-item"><label>Consumers </label><span>${b.consumers ?? '—'}</span></div>
1204
1645
  </div>`
1205
- : `<div style="color:var(--red)">unreachable${b.error ? ' — ' + escapeHtml(b.error) : ''}</div>`;
1646
+ : renderProblem(brokerProblem(b.error));
1206
1647
 
1207
1648
  const statelessCell = hs.mcpStateless == null
1208
1649
  ? dim('unknown')
1209
1650
  : hs.mcpStateless ? 'stateless' : '<span style="color:var(--yellow)">stateful</span>';
1210
- const hindsightBody = `
1651
+ const hindsightMeta = `
1211
1652
  <div class="card-meta" style="padding:0">
1212
1653
  <div class="meta-item"><label>Container </label><span>${hs.containerStatus ? escapeHtml(hs.containerStatus) : dim('absent')}</span></div>
1213
1654
  <div class="meta-item"><label>Model </label><span>${hs.model ? escapeHtml(hs.model) : dim('unknown')}</span></div>
1214
1655
  <div class="meta-item"><label>Provider </label><span>${hs.provider ? escapeHtml(hs.provider) : dim('unknown')}</span></div>
1215
1656
  <div class="meta-item"><label>MCP </label><span>${statelessCell}</span></div>
1216
1657
  </div>`;
1658
+ // When hindsight isn't serving, lead with the actionable remediation,
1659
+ // then still show the (now-stale) container/model meta beneath it.
1660
+ const hindsightBody = hs.running === false
1661
+ ? renderProblem(problemFor('hindsight-down', {})) + hindsightMeta
1662
+ : hindsightMeta;
1217
1663
 
1218
1664
  let hostdBody;
1219
1665
  if (!hd.auditLogPresent) {
1220
- hostdBody = dim(hd.error ? `audit log error: ${hd.error}` : 'no audit log yet (hostd has handled no privileged verbs)');
1666
+ // A genuine read error stays a red dim line; the benign "empty audit
1667
+ // log" case is an informational ProblemDetail (not an error state).
1668
+ hostdBody = hd.error
1669
+ ? `<div style="color:var(--red)">${escapeHtml('audit log error: ' + hd.error)}</div>`
1670
+ : renderProblem(problemFor('hostd-no-audit', {}));
1221
1671
  } else if (!hd.recent || hd.recent.length === 0) {
1222
1672
  hostdBody = dim('audit log present, no entries');
1223
1673
  } else {
1674
+ // Tri-state, not a binary ok/!ok — async mutations log a `started`
1675
+ // row (exit_code null) that is NOT a failure; painting it red made the
1676
+ // panel a wall of false-alarm red. Map: error/non-zero exit → red,
1677
+ // denied → grey, started/in-flight → blue, completed+exit0 → green.
1678
+ const verbStatus = (e) => {
1679
+ if (e.result === 'denied') return { cls: 'denied', dotStyle: 'background:var(--text-dim)', label: 'denied' };
1680
+ if (e.result === 'error' || (e.exit_code != null && e.exit_code !== 0))
1681
+ return { cls: 'err', dotStyle: 'background:var(--red)', label: e.result || 'error' };
1682
+ if (e.result === 'started' || e.exit_code == null)
1683
+ return { cls: 'started', dotStyle: 'background:var(--blue);box-shadow:0 0 6px var(--blue)', label: e.result || 'in progress' };
1684
+ return { cls: 'ok', dotStyle: 'background:var(--green);box-shadow:0 0 6px var(--green)', label: e.result || 'ok' };
1685
+ };
1224
1686
  const rows = hd.recent.slice().reverse().map(e => {
1225
1687
  const caller = e.caller && e.caller.kind === 'agent' ? escapeHtml(e.caller.name) : 'operator';
1226
- const ok = e.result === 'ok' || e.exit_code === 0;
1688
+ const st = verbStatus(e);
1689
+ // The one diagnostic field — stderr_tail/error — was previously
1690
+ // dropped, making EROFS/reconcile failures unreadable. Surface it.
1691
+ const detail = e.stderr_tail || e.error;
1692
+ const detailRow = (st.cls === 'err' || st.cls === 'denied') && detail
1693
+ ? `<tr><td colspan="4" style="color:var(--red);font-size:.72rem;white-space:pre-wrap;word-break:break-word;padding-top:0">${escapeHtml(String(detail).slice(0, 400))}</td></tr>`
1694
+ : '';
1227
1695
  return `<tr>
1228
- <td>${dot(ok)} ${escapeHtml(e.op || '?')}</td>
1696
+ <td><span class="status-dot" style="display:inline-block;vertical-align:middle;${st.dotStyle}"></span> ${escapeHtml(e.op || '?')}</td>
1229
1697
  <td>${caller}</td>
1230
- <td>${escapeHtml(e.result || '?')}${e.exit_code != null ? ` (${e.exit_code})` : ''}</td>
1698
+ <td>${escapeHtml(st.label)}${e.exit_code != null ? ` (${e.exit_code})` : ''}</td>
1231
1699
  <td>${escapeHtml(shortTs(e.ts))}</td>
1232
- </tr>`;
1700
+ </tr>${detailRow}`;
1233
1701
  }).join('');
1234
1702
  hostdBody = `<table class="accounts-table" style="margin-top:0.5rem">
1235
1703
  <thead><tr><th>Verb</th><th>Caller</th><th>Result</th><th>When</th></tr></thead>
1236
1704
  <tbody>${rows}</tbody></table>`;
1237
1705
  }
1238
1706
 
1707
+ // Subtle freshness hint: the cached object carries `stale:true` when
1708
+ // it's being served past its TTL while a background refresh runs.
1709
+ const updating = h.stale
1710
+ ? `<span style="color:var(--text-dim);font-size:.7rem;margin-left:.4rem">(updating…)</span>`
1711
+ : '';
1239
1712
  container.innerHTML = `
1713
+ ${updating ? `<div style="margin-bottom:.4rem">System health ${updating}</div>` : ''}
1240
1714
  <div class="agents-grid">
1241
1715
  <div class="agent-card"><div class="card-header" style="cursor:default">
1242
1716
  ${dot(b.reachable)}<span class="agent-name">auth-broker</span></div>
@@ -1267,9 +1741,18 @@
1267
1741
  function renderOAuthAccountCard(a, opts) {
1268
1742
  const provider = (opts && opts.provider) || '';
1269
1743
  const agentNames = (opts && opts.agentNames) || [];
1270
- const expires = a.expiresAt ? formatTimestamp(a.expiresAt) : _dimC('—');
1744
+ // expiresAt is the SHORT-LIVED access-token expiry, NOT connection
1745
+ // health. For a broker-known slot the refresh token keeps it alive, so a
1746
+ // past value means "lazily refreshes on next use" — rendering it as
1747
+ // "Expires 23d ago" made healthy connected accounts look broken (the
1748
+ // "connections aren't accurate" complaint). Don't alarm on it.
1749
+ const expires = !a.expiresAt
1750
+ ? _dimC('—')
1751
+ : (a.brokerKnown && a.expiresAt < Date.now())
1752
+ ? '<span style="color:var(--text-dim)">auto-refreshes</span>'
1753
+ : formatTimestamp(a.expiresAt);
1271
1754
  const known = a.brokerKnown
1272
- ? '<span class="usage-pill primary">slot present</span>'
1755
+ ? '<span class="usage-pill primary">connected</span>'
1273
1756
  : '<span style="color:var(--yellow)">config-only (no broker slot)</span>';
1274
1757
  const acl = (a.enabledFor && a.enabledFor.length)
1275
1758
  ? a.enabledFor.map(escapeHtml).join(', ')
@@ -1395,15 +1878,41 @@
1395
1878
  ${fullAccessBanner}${notionBody}
1396
1879
  </div>`;
1397
1880
 
1398
- container.innerHTML = googleSection + microsoftSection + notionSection;
1881
+ // Tab-level degraded banner: when every OAuth account (Google +
1882
+ // Microsoft) is config-only (no live broker slot) and there's at least
1883
+ // one, the broker data is unavailable — surface that ABOVE the sections
1884
+ // without blanking the config-only cards.
1885
+ const oauth = [...google, ...microsoft];
1886
+ const allConfigOnly = oauth.length > 0 && oauth.every(a => a.brokerKnown === false);
1887
+ const degradedBanner = allConfigOnly
1888
+ ? renderProblem(problemFor('connections-degraded', {}))
1889
+ : '';
1890
+
1891
+ container.innerHTML = degradedBanner + googleSection + microsoftSection + notionSection;
1399
1892
  }
1400
1893
 
1401
1894
  function renderSchedule(data) {
1402
1895
  const container = document.getElementById('schedule');
1403
1896
  const entries = (data && data.entries) || [];
1404
1897
  const recent = (data && data.recentByAgent) || {};
1898
+ const degraded = !!(data && data.degraded);
1899
+ const truncated = !!(data && data.truncated);
1900
+ // Degraded banner: hostd is unreachable, so this is the base-config-only
1901
+ // view (per-agent cascade overrides may be absent). Render it ABOVE the
1902
+ // cards — never instead of them — so the operator sees both the
1903
+ // incompleteness AND whatever entries we DO have.
1904
+ const degradedBanner = degraded
1905
+ ? renderProblem(problemFor('schedule-degraded', { reason: data && data.reason }))
1906
+ : '';
1907
+ const truncatedNote = truncated
1908
+ ? '<div style="font-size:.78rem;color:var(--text-dim);margin-bottom:.75rem">(some entries trimmed to fit)</div>'
1909
+ : '';
1405
1910
  if (entries.length === 0) {
1406
- container.innerHTML = '<div class="loading">No <code>schedule:</code> entries in any agent\'s cascade-resolved config.</div>';
1911
+ // A bare "No schedule entries" is misleading when hostd is just down —
1912
+ // show the actionable degraded banner instead.
1913
+ container.innerHTML = degraded
1914
+ ? degradedBanner
1915
+ : '<div class="loading">No <code>schedule:</code> entries in any agent\'s cascade-resolved config.</div>';
1407
1916
  return;
1408
1917
  }
1409
1918
  const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
@@ -1440,21 +1949,84 @@
1440
1949
  </div>
1441
1950
  </div>`;
1442
1951
  }).join('');
1443
- container.innerHTML = cards;
1952
+ container.innerHTML = degradedBanner + truncatedNote + cards;
1444
1953
  }
1445
1954
 
1446
- function renderApprovals(data) {
1955
+ function renderApprovals(data, grants) {
1447
1956
  const container = document.getElementById('approvals');
1957
+ // Two independent sections: the operator's REAL standing capability
1958
+ // grants (broker grants DB — the actually-useful one), then the
1959
+ // kernel decision ledger (honestly empty on most installs). They
1960
+ // degrade independently — one being unreachable never blanks the
1961
+ // other.
1962
+ container.innerHTML =
1963
+ renderStandingGrants(grants) + renderApprovalDecisions(data);
1964
+ }
1965
+
1966
+ // Standing capability grants from the vault-broker grants DB — what
1967
+ // `switchroom vault grants` lists. write_allow grants are the sensitive
1968
+ // ones (highlighted), mirroring the Drive-scope highlight below.
1969
+ function renderStandingGrants(grants) {
1448
1970
  const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
1971
+ const heading = '<h3 style="margin:0 0 0.5rem;font-size:0.95rem">Standing capability grants</h3>';
1972
+ if (!grants || grants.reachable === false) {
1973
+ return heading +
1974
+ renderProblem(problemFor('grants-unreachable', { error: grants && grants.error }));
1975
+ }
1976
+ const rows = grants.grants || [];
1977
+ if (rows.length === 0) {
1978
+ return heading +
1979
+ '<div class="loading" style="color:var(--text-dim)">No standing capability grants.</div>';
1980
+ }
1981
+ const now = Date.now();
1982
+ const chipList = (keys) => (keys && keys.length)
1983
+ ? keys.map(k => `<span class="chip">${escapeHtml(k)}</span>`).join(' ')
1984
+ : dim('—');
1985
+ const body = rows.map(g => {
1986
+ const expired = g.expiresAt != null && g.expiresAt < now;
1987
+ const writeCell = (g.writeKeys && g.writeKeys.length)
1988
+ ? `<span class="usage-pill primary" title="write grants are the sensitive ones">write</span> ` +
1989
+ g.writeKeys.map(k => `<span class="chip">${escapeHtml(k)}</span>`).join(' ')
1990
+ : dim('read-only');
1991
+ const expiresCell = g.expiresAt == null
1992
+ ? dim('never')
1993
+ : `<span style="${expired ? 'color:var(--text-dim)' : ''}">${formatTimestamp(g.expiresAt)}${expired ? ' (expired)' : ''}</span>`;
1994
+ return `
1995
+ <tr>
1996
+ <td>${escapeHtml(g.agent || '—')}</td>
1997
+ <td>${chipList(g.keys)}</td>
1998
+ <td>${writeCell}</td>
1999
+ <td>${expiresCell}</td>
2000
+ <td>${g.createdAt ? formatTimestamp(g.createdAt) : dim('—')}</td>
2001
+ <td>${g.description ? escapeHtml(g.description) : dim('—')}</td>
2002
+ </tr>`;
2003
+ }).join('');
2004
+ return heading + `
2005
+ <div style="margin-bottom:0.6rem;font-size:0.8rem;color:var(--text-dim)">
2006
+ Read-only view via the vault-broker operator socket — ${rows.length} grant(s). Write grants highlighted.
2007
+ </div>
2008
+ <div class="accounts-table-wrap">
2009
+ <table class="accounts-table">
2010
+ <thead><tr>
2011
+ <th>Agent</th><th>Keys (read)</th><th>Write</th>
2012
+ <th>Expires</th><th>Granted</th><th>Description</th>
2013
+ </tr></thead>
2014
+ <tbody>${body}</tbody>
2015
+ </table>
2016
+ </div>`;
2017
+ }
2018
+
2019
+ // Kernel decision ledger (RFC B) — kept as-is. Honestly empty on most
2020
+ // installs (the daily Allow/Deny taps don't write it); that's correct.
2021
+ function renderApprovalDecisions(data) {
2022
+ const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
2023
+ const heading = '<h3 style="margin:1.25rem 0 0.5rem;font-size:0.95rem">Approval decision ledger</h3>';
1449
2024
  if (!data || data.reachable === false) {
1450
- const why = (data && data.error) ? escapeHtml(data.error) : 'approval-kernel not reachable from host';
1451
- container.innerHTML = `<div class="loading">Approval ledger unavailable — ${why}</div>`;
1452
- return;
2025
+ return heading + renderProblem(approvalsProblem(data && data.error));
1453
2026
  }
1454
2027
  const decisions = data.decisions || [];
1455
2028
  if (decisions.length === 0) {
1456
- container.innerHTML = '<div class="loading">No approval decisions recorded yet.</div>';
1457
- return;
2029
+ return heading + '<div class="loading">No approval decisions recorded yet.</div>';
1458
2030
  }
1459
2031
  const now = Date.now();
1460
2032
  const statusOf = (d) => {
@@ -1482,7 +2054,7 @@
1482
2054
  <td title="${d.revoke_reason ? escapeHtml(d.revoke_reason) : ''}">${d.revoked_at ? formatTimestamp(d.revoked_at) : dim('—')}</td>
1483
2055
  </tr>`;
1484
2056
  }).join('');
1485
- container.innerHTML = `
2057
+ return heading + `
1486
2058
  <div style="margin-bottom:0.6rem;font-size:0.8rem;color:var(--text-dim)">
1487
2059
  Read-only view via the kernel operator socket — ${decisions.length} decision(s). Drive scopes highlighted.
1488
2060
  </div>
@@ -1604,9 +2176,15 @@
1604
2176
  async function toggleLogs(name) {
1605
2177
  if (openLogs.has(name)) {
1606
2178
  openLogs.delete(name);
2179
+ unsubscribeLogs(name);
1607
2180
  } else {
1608
2181
  openLogs.add(name);
2182
+ // One-shot paint for instant feedback, then subscribe to the
2183
+ // hostd-poll live stream (replaces the panel every few seconds).
1609
2184
  await loadLogs(name);
2185
+ render();
2186
+ subscribeLogs(name);
2187
+ return;
1610
2188
  }
1611
2189
  render();
1612
2190
  }
@@ -1641,9 +2219,14 @@
1641
2219
  }
1642
2220
 
1643
2221
  function connectWebSocket() {
1644
- let wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`;
1645
- if (TOKEN) wsUrl += `?token=${encodeURIComponent(TOKEN)}`;
1646
- ws = new WebSocket(wsUrl);
2222
+ const wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`;
2223
+ // Browsers can't set Authorization on a WS upgrade, but they CAN set
2224
+ // Sec-WebSocket-Protocol. The server already reads the bearer token
2225
+ // from that header (extractBearerToken → ["bearer", token]) and
2226
+ // ignores any ?token= query param — so ride the subprotocol. No
2227
+ // token (Tailscale-identified bind) → no subprotocol → the server
2228
+ // falls back to the Tailscale-User-Login header, unchanged.
2229
+ ws = TOKEN ? new WebSocket(wsUrl, ['bearer', TOKEN]) : new WebSocket(wsUrl);
1647
2230
 
1648
2231
  ws.onmessage = (event) => {
1649
2232
  try {
@@ -1651,13 +2234,31 @@
1651
2234
  if (msg.type === 'log' && msg.agent) {
1652
2235
  const el = document.getElementById(`log-output-${msg.agent}`);
1653
2236
  if (el && openLogs.has(msg.agent)) {
1654
- el.textContent += msg.data;
2237
+ // hostd-poll stream sends REPLACE (the latest tail). The
2238
+ // legacy docker-follow stream appended; keep both shapes so
2239
+ // an old payload (no `replace`) still works.
2240
+ if (msg.replace) {
2241
+ el.textContent = msg.data || '(no output)';
2242
+ } else {
2243
+ el.textContent += msg.data;
2244
+ }
1655
2245
  el.scrollTop = el.scrollHeight;
1656
2246
  }
2247
+ } else if (msg.type === 'log_error' && msg.agent) {
2248
+ const el = document.getElementById(`log-output-${msg.agent}`);
2249
+ if (el && openLogs.has(msg.agent)) {
2250
+ el.textContent = `Error: ${msg.data || 'log stream unavailable'}`;
2251
+ }
1657
2252
  }
1658
2253
  } catch {}
1659
2254
  };
1660
2255
 
2256
+ ws.onopen = () => {
2257
+ // Re-subscribe any panels that were left open across a reconnect
2258
+ // so a dropped socket doesn't leave a permanently-dead log panel.
2259
+ for (const name of openLogs) subscribeLogs(name);
2260
+ };
2261
+
1661
2262
  ws.onclose = () => {
1662
2263
  setTimeout(connectWebSocket, 3000);
1663
2264
  };
@@ -1667,6 +2268,20 @@
1667
2268
  };
1668
2269
  }
1669
2270
 
2271
+ // Send a {subscribe} for an agent's live-log poll stream, guarded on
2272
+ // an OPEN socket (a closed socket re-subscribes via ws.onopen above).
2273
+ function subscribeLogs(name) {
2274
+ if (ws && ws.readyState === WebSocket.OPEN) {
2275
+ try { ws.send(JSON.stringify({ type: 'subscribe', agent: name })); } catch {}
2276
+ }
2277
+ }
2278
+
2279
+ function unsubscribeLogs(name) {
2280
+ if (ws && ws.readyState === WebSocket.OPEN) {
2281
+ try { ws.send(JSON.stringify({ type: 'unsubscribe', agent: name })); } catch {}
2282
+ }
2283
+ }
2284
+
1670
2285
  // Init. Summary is the default visible tab; fetchAgents still runs
1671
2286
  // (populates the agents tab + keeps the 10s fleet poll warm + the
1672
2287
  // log WS depends on it). Summary is fetched on init + on tab-switch
@@ -1675,7 +2290,14 @@
1675
2290
  fetchSummary();
1676
2291
  fetchAgents();
1677
2292
  connectWebSocket();
1678
- setInterval(fetchAgents, 10000);
2293
+ // Fleet poll — gated on tab visibility. A phone with the dashboard
2294
+ // backgrounded (or a laptop tab the operator switched away from)
2295
+ // shouldn't keep hitting the host every 10s; the cache absorbs the
2296
+ // cost, but skipping the request entirely is strictly better. The
2297
+ // next foregrounded tick refreshes immediately.
2298
+ setInterval(() => {
2299
+ if (document.visibilityState === 'visible') fetchAgents();
2300
+ }, 10000);
1679
2301
  </script>
1680
2302
  </body>
1681
2303
  </html>