diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md
index 0328ed54..08f8e686 100644
--- a/docs/web-ui/dashboard.md
+++ b/docs/web-ui/dashboard.md
@@ -179,6 +179,17 @@ cancel flips the row to `⊘ cancelled` via the next
Cold-loaded from `/api/state.rebuild_queue`; live updates via
`rebuild_queue_changed` snapshot event.
+Below the queue, a **live build-log panel** (`#rebuild-live-log`,
+`renderRebuildLiveLog`) streams the currently-running rebuild's output
+inline — collapsible, with a live/ok/fail badge and a `↓ raw` download.
+It's keyed to the running entry's `build_log_id` and opens one
+`EventSource` to `GET /api/build-logs/id/{id}/stream` (the same stream
+the L0GS page BUILD tab uses; the stream replays accumulated output on
+connect). It lives in its own container outside `#rebuild-queue-section`
+so the queue's per-row re-render (rows rebuild as the `step` advances)
+never tears down the open stream; it hides when nothing is building and
+each row keeps its `logs →` link out to the full L0GS history.
+
**K3PT ST4T3** — destroyed-but-state-kept tombstones (size +
age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a
Spawn approval; existing state is reused), `PURG3` (wipes
diff --git a/frontend/packages/dashboard/src/core.css b/frontend/packages/dashboard/src/core.css
index 46755bdb..57509df9 100644
--- a/frontend/packages/dashboard/src/core.css
+++ b/frontend/packages/dashboard/src/core.css
@@ -33,3 +33,70 @@ body.core-shell {
/* createTabStrip toggles the native `hidden` attribute on inactive
panes; ensure it wins over any inherited display. */
.core-pane[hidden] { display: none; }
+
+/* ─── running-rebuild live log (renderRebuildLiveLog in core.js) ──────────
+ Streams the currently-running rebuild's build log inline under the queue.
+ Hidden (native `hidden` attr) when nothing is building. */
+.rebuild-live-log {
+ margin-top: 0.8em;
+ border: 1px solid var(--purple-dim);
+ border-radius: 4px;
+ background: color-mix(in srgb, var(--crust) 70%, transparent);
+}
+.rebuild-live-log-header {
+ display: flex;
+ align-items: center;
+ gap: 0.4em;
+ padding: 0.4em 0.6em;
+ font-size: 0.85em;
+ border-bottom: 1px solid var(--border);
+}
+.rebuild-live-log-toggle {
+ background: transparent;
+ border: 0;
+ color: var(--muted);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 1em;
+ padding: 0 0.2em;
+}
+.rebuild-live-log-toggle:hover { color: var(--fg); }
+.rebuild-live-log-title { color: var(--muted); }
+.rebuild-live-log-raw {
+ margin-left: auto;
+ color: var(--purple);
+ text-decoration: none;
+ font-size: 0.9em;
+}
+.rebuild-live-log-raw:hover { text-decoration: underline; }
+.rebuild-live-log-badge {
+ font-size: 0.75em;
+ font-weight: bold;
+ padding: 0.05em 0.5em;
+ border-radius: 999px;
+ letter-spacing: 0.05em;
+}
+.rebuild-live-log-badge.rll-running {
+ color: var(--amber);
+ background: color-mix(in srgb, var(--amber) 18%, transparent);
+}
+.rebuild-live-log-badge.rll-ok {
+ color: var(--green);
+ background: color-mix(in srgb, var(--green) 18%, transparent);
+}
+.rebuild-live-log-badge.rll-fail {
+ color: var(--red);
+ background: color-mix(in srgb, var(--red) 18%, transparent);
+}
+.rebuild-live-log-output {
+ margin: 0;
+ padding: 0.5em 0.7em;
+ max-height: 22em;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
+ font-size: 0.8em;
+ line-height: 1.4;
+ color: var(--fg);
+}
diff --git a/frontend/packages/dashboard/src/core.html b/frontend/packages/dashboard/src/core.html
index 51c99803..6de67577 100644
--- a/frontend/packages/dashboard/src/core.html
+++ b/frontend/packages/dashboard/src/core.html
@@ -55,6 +55,11 @@
+
+
diff --git a/frontend/packages/dashboard/src/core.js b/frontend/packages/dashboard/src/core.js
index d613a2e8..f84022ab 100644
--- a/frontend/packages/dashboard/src/core.js
+++ b/frontend/packages/dashboard/src/core.js
@@ -158,9 +158,10 @@ function rebuildQueueEntryFingerprint(entry, isChild) {
}
function renderRebuildQueue(s) {
+ const queue = s.rebuild_queue || [];
+ renderRebuildLiveLog(queue);
const root = $('rebuild-queue-section');
if (!root) return;
- const queue = s.rebuild_queue || [];
if (!queue.length) {
rebuildQueueRowCache.clear();
@@ -294,6 +295,121 @@ function renderQueueEntry(entry, _byId, isChild) {
return li;
}
+// ─── running-rebuild live log ─────────────────────────────────────────────
+// One persistent live-log panel for the currently-running rebuild (the queue
+// runs one build at a time). Keyed to the running entry's build_log_id and
+// kept in its own container (#rebuild-live-log) so the queue's row re-render
+// — which rebuilds rows as the build step advances — never tears down the open
+// SSE stream. Reuses the build-log stream the logs page BUILD tab uses
+// (GET /api/build-logs/id/{id}/stream; frames {stdout_append, stderr_append,
+// done, status}); the stream replays accumulated output on connect, so
+// attaching mid-build still shows the backlog.
+let liveLogEs = null;
+let liveLogId = null;
+let liveLogDone = false;
+let liveLogCollapsed = false;
+
+function closeLiveLogStream() {
+ if (liveLogEs) { liveLogEs.close(); liveLogEs = null; }
+}
+
+function renderRebuildLiveLog(queue) {
+ const root = $('rebuild-live-log');
+ if (!root) return;
+ const running = (queue || []).find(
+ (e) => e.state === 'running' && e.build_log_id != null);
+
+ if (!running) {
+ // No running build with a log → tear down + hide.
+ closeLiveLogStream();
+ liveLogId = null;
+ liveLogDone = false;
+ if (!root.hidden) { root.hidden = true; root.replaceChildren(); }
+ return;
+ }
+ // Same build still streaming (or already finished) → leave the panel as-is.
+ // Re-renders fire on every queue mutation; don't reconnect mid-build, and
+ // don't reopen a stream that already sent `done`.
+ if (running.build_log_id === liveLogId && (liveLogEs || liveLogDone)) return;
+
+ // New running build → (re)build the panel + open its stream.
+ closeLiveLogStream();
+ liveLogId = running.build_log_id;
+ liveLogDone = false;
+ root.hidden = false;
+ root.replaceChildren();
+
+ const pre = el('pre', { class: 'rebuild-live-log-output' }, '');
+ pre.hidden = liveLogCollapsed;
+ const badge = el('span', { class: 'rebuild-live-log-badge rll-running' }, 'live');
+ const toggle = el('button', {
+ type: 'button',
+ class: 'rebuild-live-log-toggle',
+ 'aria-expanded': String(!liveLogCollapsed),
+ title: liveLogCollapsed ? 'expand live log' : 'collapse live log',
+ }, liveLogCollapsed ? '▸' : '▾');
+ toggle.addEventListener('click', () => {
+ liveLogCollapsed = !liveLogCollapsed;
+ pre.hidden = liveLogCollapsed;
+ toggle.textContent = liveLogCollapsed ? '▸' : '▾';
+ toggle.setAttribute('aria-expanded', String(!liveLogCollapsed));
+ toggle.title = liveLogCollapsed ? 'expand live log' : 'collapse live log';
+ });
+ const header = el('div', { class: 'rebuild-live-log-header' },
+ toggle, ' ',
+ el('span', { class: 'rebuild-live-log-title' }, 'live build log — '),
+ el('code', { class: 'rqe-agent' }, running.agent),
+ ' ', el('span', { class: 'rqe-kind' }, running.kind || 'rebuild'),
+ ' ', badge, ' ',
+ el('a', {
+ class: 'rebuild-live-log-raw',
+ href: '/api/build-logs/id/' + running.build_log_id + '/raw',
+ download: 'build-log-' + running.build_log_id + '.txt',
+ }, '↓ raw'),
+ );
+ root.append(header, pre);
+
+ // Sticky-bottom: auto-scroll to bottom on new lines unless the operator
+ // scrolled up (track intent via the scroll event — same as the logs page).
+ let atBottom = true;
+ pre.addEventListener('scroll', () => {
+ atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
+ });
+
+ let stderrSeen = false;
+ const es = new EventSource('/api/build-logs/id/' + running.build_log_id + '/stream');
+ liveLogEs = es;
+ es.onmessage = (ev) => {
+ let frame;
+ try { frame = JSON.parse(ev.data); } catch { return; }
+ if (frame.stdout_append) {
+ pre.textContent += frame.stdout_append;
+ if (atBottom) pre.scrollTop = pre.scrollHeight;
+ }
+ if (frame.stderr_append) {
+ if (!stderrSeen) { pre.textContent += '\n--- stderr ---\n'; stderrSeen = true; }
+ pre.textContent += frame.stderr_append;
+ if (atBottom) pre.scrollTop = pre.scrollHeight;
+ }
+ if (frame.done) {
+ liveLogDone = true;
+ badge.className = 'rebuild-live-log-badge '
+ + (frame.status === 'ok' ? 'rll-ok' : 'rll-fail');
+ badge.textContent = frame.status || 'done';
+ if (es === liveLogEs) { es.close(); liveLogEs = null; }
+ // Keep liveLogId + liveLogDone so a re-render before the entry leaves
+ // 'running' doesn't reopen the finished stream; the next render with no
+ // running entry clears the panel.
+ }
+ };
+ es.onerror = () => {
+ if (es.readyState === EventSource.CONNECTING) return; // transient reconnect
+ badge.className = 'rebuild-live-log-badge rll-fail';
+ badge.textContent = 'stream error';
+ if (es === liveLogEs) { es.close(); liveLogEs = null; }
+ };
+}
+
// ─── kept state (tombstones) ──────────────────────────────────────────────
function renderTombstones(s) {
const root = $('tombstones-section');