diff --git a/src/server/board/sidebar.ts b/src/server/board/sidebar.ts
new file mode 100644
index 0000000..90f02ee
--- /dev/null
+++ b/src/server/board/sidebar.ts
@@ -0,0 +1,96 @@
+import { throughputSeries, areaPath, dayStart } from './viewmodel.js';
+
+/**
+ * Sidebar skeleton: budget card (über-tabs Token Insights | Verlauf; insight
+ * is the default) + live feed card. The insight pane keeps the v1 mount
+ * points (#budgetRows, #budgetTotal, #budgetReset, [data-budget-mode]) that
+ * the ported v1 budget JS (v1Budget.ts) drives; donut sub-tabs (Token/Kosten)
+ * are rendered by that same ported code.
+ */
+export function sidebarHtml(): string {
+ return `
+`;
+}
+
+/**
+ * Inline script: über-tab switching (persisted in localStorage), throughput
+ * area chart, live feed — followed by the ported v1 budget JS.
+ */
+export function sidebarJs(v1BudgetJs: string): string {
+ return `
+var DAY_MS = 24 * 3600 * 1000;
+${dayStart.toString()}
+${throughputSeries.toString()}
+${areaPath.toString()}
+(function () {
+ var OTAB_KEY = 'agenthub-budget-otab';
+ function activate(name) {
+ document.querySelectorAll('[data-otab]').forEach(function (b) {
+ b.classList.toggle('active', b.getAttribute('data-otab') === name);
+ });
+ document.querySelectorAll('[data-opane]').forEach(function (p) {
+ p.hidden = p.getAttribute('data-opane') !== name;
+ });
+ }
+ document.addEventListener('click', function (e) {
+ var btn = e.target.closest && e.target.closest('[data-otab]');
+ if (!btn) return;
+ var name = btn.getAttribute('data-otab');
+ localStorage.setItem(OTAB_KEY, name);
+ activate(name);
+ });
+ activate(localStorage.getItem(OTAB_KEY) || 'insight');
+})();
+window.__b2RenderThroughput = function (tasks) {
+ var box = document.getElementById('b2Throughput');
+ var tip = document.getElementById('b2ThroughputTip');
+ if (!box) return;
+ var s = throughputSeries(tasks, 14);
+ var p = areaPath(s, 220, 80);
+ box.innerHTML = '';
+ if (tip) {
+ var avg = s.reduce(function (a, b) { return a + b; }, 0) / s.length;
+ tip.textContent = 'Erledigte Tasks · 14 Tage · Ø ' + avg.toFixed(1) + '/Tag';
+ }
+};
+window.__b2FeedPush = function (html) {
+ var feed = document.getElementById('b2Feed');
+ if (!feed) return;
+ var div = document.createElement('div');
+ div.innerHTML = html;
+ feed.insertBefore(div, feed.firstChild);
+ while (feed.children.length > 5) feed.removeChild(feed.lastChild);
+};
+${v1BudgetJs}`;
+}
diff --git a/src/server/board/v1Budget.ts b/src/server/board/v1Budget.ts
new file mode 100644
index 0000000..13d62b4
--- /dev/null
+++ b/src/server/board/v1Budget.ts
@@ -0,0 +1,299 @@
+/**
+ * Verbatim port of the v1 budget-panel client JS (old src/server/board.ts,
+ * lines 1466–1750). Kept byte-identical on purpose: session/total baseline
+ * logic, donut signature throttling and the reset handler are battle-tested.
+ *
+ * Depends on page-scope helpers provided by the columns/data-layer port:
+ * esc, getJSON, hashColor, agentSpec, toast. Mount points expected in the DOM:
+ * #budgetRows, #budgetTotal, #budgetReset, [data-budget-mode].
+ */
+export const v1BudgetJs = `
+ // ── Roster ──────────────────────────────────────────────────────────────
+ // AGENTS backs the budget panel + resolves a title's "name:" prefix to a
+ // known agent when the architect drags a card into In Progress.
+ var AGENTS = [];
+ var BUDGET = null;
+ var budgetMode = localStorage.getItem('agenthub-budget-mode') || 'session';
+ var donutMetric = localStorage.getItem('agenthub-donut-metric') || 'tokens';
+ var lastDonutSignature = '';
+ var lastDonutAt = 0;
+ var DONUT_REFRESH_MS = 180000;
+
+ async function loadAgents() {
+ try {
+ AGENTS = await getJSON('/agents');
+ } catch (_) { AGENTS = []; }
+ }
+
+ // ── Budget panel ────────────────────────────────────────────────────────
+ function fmtTokens(n) {
+ n = Number(n) || 0;
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
+ return String(Math.round(n));
+ }
+ function fmtEur(n) { return '\\u20ac' + (Number(n) || 0).toFixed(2); }
+ function maxTok(list) { return list.reduce(function(m, a) { return Math.max(m, a.tokens || 0); }, 0); }
+ function cloneAgent(a) {
+ return {
+ name: a.name,
+ role: a.role,
+ model: a.model,
+ kind: a.kind,
+ tokens: Number(a.tokens) || 0,
+ realTokens: Number(a.realTokens) || 0,
+ estimatedTokens: Number(a.estimatedTokens) || 0,
+ estimated: !!a.estimated,
+ costEur: Number(a.costEur) || 0,
+ taskCount: Number(a.taskCount) || 0
+ };
+ }
+ function currentSnapshot(rep) {
+ var byAgent = {};
+ (rep && rep.agents ? rep.agents : []).forEach(function(a) {
+ byAgent[a.name] = { tokens: Number(a.tokens) || 0, costEur: Number(a.costEur) || 0 };
+ });
+ return { createdAt: Date.now(), byAgent: byAgent };
+ }
+ function readBaseline() {
+ try { return JSON.parse(localStorage.getItem('agenthub-budget-baseline') || 'null'); } catch (_) { return null; }
+ }
+ function writeBaseline(rep) {
+ localStorage.setItem('agenthub-budget-baseline', JSON.stringify(currentSnapshot(rep)));
+ }
+ function applyBudgetMode(rep) {
+ var agents = (rep && rep.agents ? rep.agents : []).map(cloneAgent);
+ if (budgetMode === 'session') {
+ var base = readBaseline();
+ if (!base) { writeBaseline(rep); base = readBaseline(); }
+ agents.forEach(function(a) {
+ var b = base && base.byAgent ? base.byAgent[a.name] : null;
+ var baseTokens = b ? Number(b.tokens) || 0 : 0;
+ var baseCost = b ? Number(b.costEur) || 0 : 0;
+ a.tokens = Math.max(0, a.tokens - baseTokens);
+ a.costEur = Math.max(0, a.costEur - baseCost);
+ a.realTokens = Math.min(a.realTokens, a.tokens);
+ a.estimatedTokens = Math.max(0, a.tokens - a.realTokens);
+ a.estimated = a.estimated && a.estimatedTokens > 0;
+ });
+ }
+ var totals = {
+ tokens: agents.reduce(function(s, a) { return s + (a.tokens || 0); }, 0),
+ costEur: agents.reduce(function(s, a) { return s + (a.costEur || 0); }, 0),
+ estimated: agents.some(function(a) { return a.estimated; })
+ };
+ return { agents: agents, totals: totals };
+ }
+ function companySpec(kind) {
+ var key = String(kind || '').toLowerCase();
+ var map = {
+ anthropic: { name: 'Anthropic', color: '#D97757' },
+ openai: { name: 'OpenAI', color: '#10A37F' },
+ moonshot: { name: 'Moonshot', color: '#7C3AED' }
+ };
+ return map[key] || { name: key ? key.charAt(0).toUpperCase() + key.slice(1) : 'Unknown', color: hashColor(key || 'unknown') };
+ }
+ function donutPolar(cx, cy, r, angleDeg) {
+ var rad = angleDeg * Math.PI / 180;
+ return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
+ }
+ function donutArc(cx, cy, r, startDeg, endDeg) {
+ var start = donutPolar(cx, cy, r, startDeg);
+ var end = donutPolar(cx, cy, r, endDeg);
+ var largeArc = endDeg - startDeg > 180 ? 1 : 0;
+ return 'M ' + start.x.toFixed(2) + ' ' + start.y.toFixed(2) + ' A ' + r + ' ' + r + ' 0 ' + largeArc + ' 1 ' + end.x.toFixed(2) + ' ' + end.y.toFixed(2);
+ }
+ function aggregateByCompany(agents, field) {
+ var byKind = {};
+ (agents || []).forEach(function(a) {
+ var kind = String(a.kind || '').toLowerCase();
+ if (!kind || !(a.tokens > 0)) return;
+ var spec = companySpec(kind);
+ if (!byKind[kind]) byKind[kind] = { kind: kind, name: spec.name, color: spec.color, value: 0 };
+ byKind[kind].value += Number(a[field]) || 0;
+ });
+ return Object.keys(byKind).map(function(k) { return byKind[k]; }).sort(function(a, b) { return b.value - a.value; });
+ }
+ function halfDonut(title, rows, centerValue, centerLabel, formatValue) {
+ var total = rows.reduce(function(s, x) { return s + (x.value || 0); }, 0);
+ var safeTotal = Math.max(1, total);
+ var cx = 110, cy = 122, r = 90, stroke = 18;
+ var angle = 180;
+ var paths = '