switchroom 0.15.26 → 0.15.28

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