Compare commits

..

No commits in common. "c9e240b068ff2cf2a732e3ed852c5f60a8a0be01" and "2fb9db15b1797f133a23ad28eac2e664bb6532b5" have entirely different histories.

61 changed files with 2237 additions and 5774 deletions

1
.gitignore vendored
View File

@ -5,4 +5,3 @@ dist/
coverage/ coverage/
.agenthub/ .agenthub/
.worktrees/ .worktrees/
.superpowers/

File diff suppressed because one or more lines are too long

View File

@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#38bdf8"/>
<stop offset="1" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M12 2l8.5 5v10L12 22l-8.5-5V7z"/>
<circle cx="12" cy="12" r="3.2" fill="#0e1226"/>
</svg>

Before

Width:  |  Height:  |  Size: 368 B

View File

@ -1,928 +0,0 @@
# AgentHub Board v2 Redesign — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Die Board-Seite (`/board`) bekommt das freigegebene v2-Redesign (Glass-Dashboard, animierte KPIs, Tab-Sidebar, Live-Feed, Logo + Splash) als modularisierte Neuimplementierung von `src/server/board.ts` — ohne Funktionsverlust.
**Architecture:** Fastify rendert weiterhin einen HTML-String beim Serverstart (kein Build-Step, kein Framework). `src/server/board.ts` (2098 Zeilen Monolith) wird ersetzt durch `src/server/board/` mit Fokus-Modulen. Browser-Logik, die berechenbar ist (KPI-Modelle, Chart-Pfade, Chip-Kappung), liegt als **reine TypeScript-Funktionen** in `viewmodel.ts` — diese werden per `fn.toString()` in das Inline-`<script>` injiziert und sind so mit vitest direkt testbar. Bestehende Interaktions-JS (Drag&Drop, SSE, Live-Konsole, Modals, Budget-Baseline) wird aus dem alten `board.ts` portiert.
**Tech Stack:** TypeScript (nodenext, `.js`-Importe), Fastify, Vanilla JS + SSE im Browser, vitest.
**Spec:** `docs/superpowers/specs/2026-07-20-board-v2-redesign-design.md`
**Visuelle Referenz (freigegebenes Mockup, enthält produktionsnahes CSS/HTML):** `.superpowers/brainstorm/22429-1784510154/content/board-v25-final.html` — die `.v25`-Klasse ist beim Portieren durch `.b2` (Board-v2-Namespace) zu ersetzen, Farbwerte bleiben identisch.
**Wichtige Datenfakten (verifiziert):**
- `/budget``BudgetReport { agents: AgentBudget[], totals, assumptions, generatedAt }`**keine Tageshistorie**. Das Verlauf-Chart zeigt daher **erledigte Tasks/Tag** (aus `/tasks` clientseitig ableitbar), nicht Tokens.
- Budget-Reset ist rein clientseitig (`writeBaseline(BUDGET)` + localStorage), kein API-Call — aus altem `board.ts:1739-1749` portieren.
- Session/Total-Umschaltung existiert in v1 (`budgetMode`, localStorage `agenthub-budget-mode`); Sub-Tabs Token/Kosten existieren als `data-donut-tab` (`board.ts:1732-1737`) — beides übernehmen.
- `routes.ts:16` importiert `renderBoardHtml` aus `./board.js`; der Import wird auf `./board/index.js` geändert (Verzeichnis statt Datei).
- Tests laufen mit `npx vitest run tests/<file>.test.ts` (Repo nutzt vitest, Tests importieren aus `../src/...js`).
---
### Task 1: Logo-Asset + ViewModel-Helper (TDD)
**Files:**
- Create: `assets/logo.svg`
- Create: `src/server/board/viewmodel.ts`
- Test: `tests/boardV2-viewmodel.test.ts`
- [ ] **Step 1: Logo-Datei anlegen**
`assets/logo.svg` (Hexagon-Gradient wie im freigegebenen Mockup):
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#38bdf8"/>
<stop offset="1" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M12 2l8.5 5v10L12 22l-8.5-5V7z"/>
<circle cx="12" cy="12" r="3.2" fill="#0e1226"/>
</svg>
```
- [ ] **Step 2: Failing Tests für die ViewModel-Helper schreiben**
`tests/boardV2-viewmodel.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import {
capChips, doneStats, backlogSeries, throughputSeries, areaPath,
type KpiTask,
} from '../src/server/board/viewmodel.js';
const day = 24 * 3600 * 1000;
const iso = (msAgo: number) => new Date(Date.now() - msAgo).toISOString();
describe('capChips', () => {
it('shows all chips when at most max', () => {
const chips = [{ name: 'claude', minutes: 5 }, { name: 'codex', minutes: 2 }];
expect(capChips(chips, 3)).toEqual({ visible: chips, hidden: 0 });
});
it('caps at max and reports the hidden count', () => {
const chips = ['a', 'b', 'c', 'd', 'e'].map((name) => ({ name, minutes: 1 }));
const r = capChips(chips, 3);
expect(r.visible.map((c) => c.name)).toEqual(['a', 'b', 'c']);
expect(r.hidden).toBe(2);
});
});
describe('doneStats', () => {
it('computes share and weekly count, excluding cancelled from total', () => {
const tasks: KpiTask[] = [
{ id: '1', status: 'done', updatedAt: iso(2 * day) },
{ id: '2', status: 'done', updatedAt: iso(10 * day) },
{ id: '3', status: 'open' },
{ id: '4', status: 'cancelled' },
];
const r = doneStats(tasks);
expect(r.done).toBe(2);
expect(r.total).toBe(3); // cancelled excluded
expect(r.pct).toBe(67);
expect(r.doneThisWeek).toBe(1);
});
it('handles empty input', () => {
expect(doneStats([])).toEqual({ done: 0, total: 0, pct: 0, doneThisWeek: 0 });
});
});
describe('backlogSeries', () => {
it('returns one value per day, oldest first, ending today', () => {
const tasks: KpiTask[] = [
{ id: '1', status: 'open', createdAt: iso(3 * day) },
{ id: '2', status: 'done', createdAt: iso(10 * day), updatedAt: iso(1 * day) },
];
const s = backlogSeries(tasks, 14);
expect(s).toHaveLength(14);
expect(s[13]).toBe(1); // today: only the open task is backlog
expect(s[0]).toBe(1); // 13 days ago: only task 2 existed and was not done yet
});
});
describe('throughputSeries', () => {
it('counts done tasks per day', () => {
const tasks: KpiTask[] = [
{ id: '1', status: 'done', updatedAt: iso(0) },
{ id: '2', status: 'done', updatedAt: iso(0) },
{ id: '3', status: 'done', updatedAt: iso(5 * day) },
{ id: '4', status: 'open', updatedAt: iso(0) },
];
const s = throughputSeries(tasks, 14);
expect(s).toHaveLength(14);
expect(s[13]).toBe(2); // today
expect(s[8]).toBe(1); // 5 days ago
expect(s.reduce((a, b) => a + b, 0)).toBe(3);
});
});
describe('areaPath', () => {
it('builds line and area paths scaled to width/height', () => {
const { line, area } = areaPath([0, 5, 10], 100, 50);
expect(line).toBe('M0,50 L50,25 L100,0');
expect(area).toBe('M0,50 L50,25 L100,0 L100,50 L0,50 Z');
});
it('flattens when all values are equal (no division by zero)', () => {
const { line } = areaPath([3, 3, 3], 90, 30);
expect(line).toBe('M0,15 L45,15 L90,15');
});
});
```
- [ ] **Step 3: Tests laufen lassen und scheitern sehen**
Run: `npx vitest run tests/boardV2-viewmodel.test.ts`
Expected: FAIL — `Cannot find module '../src/server/board/viewmodel.js'`
- [ ] **Step 4: `src/server/board/viewmodel.ts` implementieren**
Komplette Datei. Alle Funktionen sind bewusst **frei von Imports und Closures** (sie werden per `.toString()` in die Browser-Seite injiziert); `declare` nur für Typen:
```ts
/**
* Pure, dependency-free helpers for the board v2 UI.
* These run BOTH in vitest (server-side) and in the browser — board/index.ts
* injects them into the inline <script> via Function.prototype.toString().
* Therefore: no imports, no closures over module state, ES2019 syntax only.
*/
export interface KpiTask {
id: string;
status?: string;
assignedTo?: string;
reviewer?: string;
createdAt?: string;
updatedAt?: string;
claimedAt?: string;
}
export interface AgentChip {
name: string;
minutes: number | null;
}
const DAY_MS = 24 * 3600 * 1000;
/** Cap a chip list at `max` visible entries, reporting how many were hidden. */
export function capChips<T>(chips: T[], max: number): { visible: T[]; hidden: number } {
const visible = chips.slice(0, Math.max(0, max));
return { visible, hidden: chips.length - visible.length };
}
/** Done-card model: share of non-cancelled tasks done + done in the last 7 days. */
export function doneStats(
tasks: KpiTask[],
now?: number,
): { done: number; total: number; pct: number; doneThisWeek: number } {
const t0 = now ?? Date.now();
const relevant = tasks.filter((t) => t.status !== 'cancelled');
const doneTasks = relevant.filter((t) => t.status === 'done');
const weekAgo = t0 - 7 * DAY_MS;
const doneThisWeek = doneTasks.filter(
(t) => t.updatedAt && new Date(t.updatedAt).getTime() >= weekAgo,
).length;
const pct = relevant.length === 0 ? 0 : Math.round((doneTasks.length / relevant.length) * 100);
return { done: doneTasks.length, total: relevant.length, pct, doneThisWeek };
}
function dayStart(ts: number): number {
const d = new Date(ts);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
/**
* Backlog (open) count for each of the last `days` days, oldest first.
* Approximation: a task counts as backlog on day D when it existed by end of D
* and was not yet moved out of 'open' (non-open tasks use updatedAt as the
* transition timestamp; still-open tasks are backlog on every day since creation).
*/
export function backlogSeries(tasks: KpiTask[], days: number, now?: number): number[] {
const today = dayStart(now ?? Date.now());
const out: number[] = [];
for (let i = days - 1; i >= 0; i--) {
const endOfDay = today - i * DAY_MS + DAY_MS - 1;
let count = 0;
for (const t of tasks) {
const created = t.createdAt ? new Date(t.createdAt).getTime() : 0;
if (created > endOfDay) continue;
if (t.status === 'open') {
count++;
} else if (t.status !== 'cancelled') {
const left = t.updatedAt ? new Date(t.updatedAt).getTime() : created;
if (left > endOfDay) count++; // was still open on that day
}
}
out.push(count);
}
return out;
}
/** Done-per-day counts for the last `days` days, oldest first. */
export function throughputSeries(tasks: KpiTask[], days: number, now?: number): number[] {
const today = dayStart(now ?? Date.now());
const out: number[] = [];
for (let i = days - 1; i >= 0; i--) {
const start = today - i * DAY_MS;
const end = start + DAY_MS - 1;
out.push(
tasks.filter(
(t) =>
t.status === 'done' &&
t.updatedAt &&
new Date(t.updatedAt).getTime() >= start &&
new Date(t.updatedAt).getTime() <= end,
).length,
);
}
return out;
}
/** Build SVG line + area path data for a value series. Values are top-anchored (max = y 0). */
export function areaPath(
values: number[],
w: number,
h: number,
): { line: string; area: string } {
if (values.length === 0) return { line: '', area: '' };
const max = Math.max(...values);
const min = Math.min(...values);
const span = max - min;
const step = values.length > 1 ? w / (values.length - 1) : 0;
const pts = values.map((v, i) => {
const x = Math.round(i * step * 100) / 100;
const y = span === 0 ? h / 2 : Math.round(((max - v) / span) * h * 100) / 100;
return `${x},${y}`;
});
const line = `M${pts.join(' L')}`;
return { line, area: `${line} L${w},${h} L0,${h} Z` };
}
/** Chip model for one status lane (in_progress → assignedTo, review → reviewer). */
export function laneChips(tasks: KpiTask[], status: string, now?: number): AgentChip[] {
const t0 = now ?? Date.now();
return tasks
.filter((t) => t.status === status)
.map((t) => {
const name = status === 'review' ? t.reviewer ?? t.assignedTo : t.assignedTo;
const since = t.claimedAt ?? t.updatedAt;
return {
name: name ?? '?',
minutes: since ? Math.max(0, Math.round((t0 - new Date(since).getTime()) / 60000)) : null,
};
})
.filter((c) => c.name !== '?');
}
```
- [ ] **Step 5: Tests laufen lassen — grün**
Run: `npx vitest run tests/boardV2-viewmodel.test.ts`
Expected: PASS (7 Tests). Dann zusätzlich sicherstellen, dass nichts anderes bricht: `npx vitest run` (Gesamtlauf) — Expected: alle bestehenden Tests grün (die neue Datei wird noch nirgends importiert).
- [ ] **Step 6: Commit**
```bash
git add assets/logo.svg src/server/board/viewmodel.ts tests/boardV2-viewmodel.test.ts
git commit -m "feat(board): v2 viewmodel helpers + logo asset"
```
---
### Task 2: Styles-Modul (Design-Tokens, Glass, Keyframes)
**Files:**
- Create: `src/server/board/styles.ts`
- Reference (CSS-Quelle): `.superpowers/brainstorm/22429-1784510154/content/board-v25-final.html` (`.v25`-Block)
- Reference (zu portierende v1-Styles): `src/server/board.ts:56-845`
- [ ] **Step 1: Failing Test**
`tests/boardV2-styles.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { boardV2Css } from '../src/server/board/styles.js';
describe('boardV2Css', () => {
it('contains the glass surface tokens and keyframes', () => {
const css = boardV2Css();
expect(css).toContain('--b2-bg');
expect(css).toContain('rgba(255,255,255,.045)'); // glass surface
expect(css).toContain('@keyframes b2-rise');
expect(css).toContain('@keyframes b2-fill');
expect(css).toContain('@keyframes b2-ping');
});
it('respects prefers-reduced-motion', () => {
expect(boardV2Css()).toContain('@media (prefers-reduced-motion: reduce)');
});
});
```
Run: `npx vitest run tests/boardV2-styles.test.ts` — Expected: FAIL (Modul fehlt).
- [ ] **Step 2: `src/server/board/styles.ts` implementieren**
`export function boardV2Css(): string` gibt den kompletten CSS-Block als Template-String zurück. Aufbau:
1. **Tokens** (auf `:root` der Board-Seite, Präfix `--b2-`): `--b2-bg:#090c18`, `--b2-surface:rgba(255,255,255,.045)`, `--b2-border:rgba(255,255,255,.09)`, `--b2-text:#eef1f8`, `--b2-muted:#8fa0c6`, `--b2-accent:#38bdf8`, `--b2-violet:#8b5cf6`, `--b2-green:#34d399`, `--b2-amber:#fbbf24`, `--b2-open:#8b949e`, Statusfarben identisch zu v1 (`src/server/ui-shared.ts` `designTokensCss()`).
2. **Basis**: Body-Hintergrund `radial-gradient(140% 120% at 15% -10%, #1c2547 0%, #0e1226 50%, #090c18 100%)`.
3. **Komponenten**: Header, KPI-Grid (4 Karten), Chips, Progress-Bar, Spalten/Karten, Sidebar-Panels, Tabs (Über + Sub), Donut, Feed, Splash-Overlay, Modals, Toasts. Das komplette, freigegebene Komponenten-CSS steht im Mockup `.superpowers/brainstorm/22429-1784510154/content/board-v25-final.html` im `<style>`-Block — Klassenpräfix `.v25``.b2` umbenennen, Keyframes `rise/fill/blink/draw/d25/ping``b2-rise` etc., und auf volle App-Größe skalieren (Mockup ist Miniatur: Schriftgrößen/Abstände ×~1.3, Spalten-Mindesthöhe entfällt zugunsten echter Inhalte).
4. **Aus v1 portieren** (unverändert nötig für bestehende Interaktionen): Drag&Drop-States (`.card.dragging`, Drop-Highlights), Live-Konsole in Karten, Modal-Styles, Toast-Styles, `metric-flash` — Quelle `src/server/board.ts:56-845`.
5. **Reduced Motion** am Ende:
```css
@media (prefers-reduced-motion: reduce) {
.b2, .b2 * { animation: none !important; transition: none !important; }
}
```
- [ ] **Step 3: Tests grün**
Run: `npx vitest run tests/boardV2-styles.test.ts` — Expected: PASS (2 Tests).
- [ ] **Step 4: Commit**
```bash
git add src/server/board/styles.ts tests/boardV2-styles.test.ts
git commit -m "feat(board): v2 glass design system css"
```
---
### Task 3: Chrome-Modul (Header mit Logo + Splash Screen)
**Files:**
- Create: `src/server/board/chrome.ts`
- Test: `tests/boardV2-chrome.test.ts`
- [ ] **Step 1: Failing Test**
`tests/boardV2-chrome.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { headerHtml, splashHtml, splashJs } from '../src/server/board/chrome.js';
describe('headerHtml', () => {
it('renders logo, project cell with chevron, nav and new-task button', () => {
const h = headerHtml('my-project');
expect(h).toContain('/card-assets/../logo.svg'.replace('/card-assets/..', '')); // /logo.svg
expect(h).toContain('my-project');
expect(h).toContain('b2-proj'); // project cell (visual only, dropdown comes with v3)
expect(h).toContain('▾');
expect(h).toContain('+ New task');
expect(h).toContain('id="connDot"');
});
it('escapes the project name', () => {
expect(headerHtml('<b>x</b>')).not.toContain('<b>x</b>');
});
});
describe('splash', () => {
it('renders overlay with logo and wordmark', () => {
const s = splashHtml();
expect(s).toContain('id="b2-splash"');
expect(s).toContain('/logo.svg');
expect(s).toContain('agenthub');
});
it('fade-out script has a hard timeout and never blocks', () => {
const js = splashJs();
expect(js).toContain('b2-splash');
expect(js).toContain('1500'); // hard cap in ms
});
});
```
Run: `npx vitest run tests/boardV2-chrome.test.ts` — Expected: FAIL.
- [ ] **Step 2: `src/server/board/chrome.ts` implementieren**
Vollständige Datei:
```ts
import { escapeHtml } from '../ui-shared.js';
const LOGO = '<img src="/logo.svg" alt="" width="22" height="22" class="b2-logo-img">';
/** Fixed app header for board v2. Project cell is visual only (dropdown = v3). */
export function headerHtml(projectName: string): string {
const name = escapeHtml(projectName);
return `
<header class="b2-hdr">
<span class="b2-brand">${LOGO}<b>agenthub</b></span>
<span class="b2-proj" title="Projektwechsel kommt mit v3">/ ${name} <span class="b2-chev"></span></span>
<nav class="b2-nav">
<b>Board</b><a href="/team">Team</a><a href="/activity">Activity</a><a href="/decisions">Decisions</a>
</nav>
<span class="b2-hdr-right">
<button type="button" class="b2-btn" id="newTaskBtn">+ New task</button>
<span class="b2-live-dot" id="connDot" title="Live-Verbindung"></span>
</span>
</header>`;
}
/** Fullscreen splash overlay, shown until first data render (hard cap 1.5s). */
export function splashHtml(): string {
return `
<div id="b2-splash" class="b2-splash" aria-hidden="true">
<div class="b2-splash-inner">
<img src="/logo.svg" alt="agenthub" width="72" height="72" class="b2-splash-logo">
<div class="b2-splash-word">agenthub</div>
</div>
</div>`;
}
/**
* Inline script: hides the splash after the first successful refresh() or after
* a hard 1500ms cap, whichever comes first. Exposes window.__b2SplashDone() so
* the data layer can signal completion.
*/
export function splashJs(): string {
return `
(function () {
var done = false;
var el = function () { return document.getElementById('b2-splash'); };
window.__b2SplashDone = function () {
if (done) return;
done = true;
var s = el();
if (!s) return;
s.classList.add('b2-splash-hide');
setTimeout(function () { if (s.parentNode) s.parentNode.removeChild(s); }, 450);
};
setTimeout(window.__b2SplashDone, 1500); // hard cap — splash must never block
})();`;
}
```
Hinweis: `escapeHtml` existiert bereits in `src/server/ui-shared.ts` und wird wiederverwendet. Die Splash-CSS (`b2-splash`, Fade-Out per `opacity`-Transition + `b2-splash-hide`, Logo-Puls) ist Teil von Task 2; falls dort noch nicht enthalten, hier ergänzen:
```css
.b2-splash { position: fixed; inset: 0; z-index: 999; display: flex; align-items: center; justify-content: center;
background: radial-gradient(140% 120% at 15% -10%, #1c2547 0%, #0e1226 50%, #090c18 100%);
transition: opacity .4s ease; }
.b2-splash-hide { opacity: 0; pointer-events: none; }
.b2-splash-logo { animation: b2-ping 1.6s ease-in-out infinite; }
.b2-splash-word { margin-top: 14px; font-size: 22px; font-weight: 700; letter-spacing: .02em; color: #eef1f8; }
```
- [ ] **Step 3: Tests grün**
Run: `npx vitest run tests/boardV2-chrome.test.ts` — Expected: PASS (4 Tests).
- [ ] **Step 4: Favicon + Logo-Route sicherstellen**
`src/server/routes.ts`: prüfen, dass `GET /logo.svg` ausgeliefert wird. Es existiert bereits eine Read-Only-Route `GET /card-assets/*` (routes.ts:64-79, Root = Repo-`assets/`). Kleinstmögliche Ergänzung direkt daneben:
```ts
app.get('/logo.svg', async (_req, reply) => {
reply.type('image/svg+xml');
return readFileSync(join(assetsRoot, 'logo.svg'));
});
```
(`assetsRoot` und `readFileSync`/`join` wie in der bestehenden `/card-assets/*`-Route verwendet — deren exakte lokalen Namen übernehmen.) Favicon im HTML-Head (Task 6, Assembly): `<link rel="icon" href="/logo.svg" type="image/svg+xml">`.
- [ ] **Step 5: Commit**
```bash
git add src/server/board/chrome.ts tests/boardV2-chrome.test.ts src/server/routes.ts
git commit -m "feat(board): v2 header, splash screen, logo route"
```
---
### Task 4: KPI-Modul
**Files:**
- Create: `src/server/board/kpis.ts`
- Test: `tests/boardV2-kpis.test.ts`
- [ ] **Step 1: Failing Test**
`tests/boardV2-kpis.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { kpiSkeletonHtml, kpiJs } from '../src/server/board/kpis.js';
describe('kpiSkeletonHtml', () => {
it('renders the four KPI cards with stable ids', () => {
const h = kpiSkeletonHtml();
for (const id of ['kpiOpen', 'kpiInProgress', 'kpiReview', 'kpiDone']) {
expect(h).toContain(`id="${id}"`);
}
expect(h).toContain('Open');
expect(h).toContain('In Progress');
expect(h).toContain('Review');
expect(h).toContain('Done');
});
});
describe('kpiJs', () => {
it('injects the pure helpers and an updateKpis entry point', () => {
const js = kpiJs();
for (const fn of ['capChips', 'doneStats', 'backlogSeries', 'areaPath', 'laneChips']) {
expect(js).toContain(`function ${fn}`);
}
expect(js).toContain('window.__b2UpdateKpis');
});
});
```
Run: `npx vitest run tests/boardV2-kpis.test.ts` — Expected: FAIL.
- [ ] **Step 2: `src/server/board/kpis.ts` implementieren**
```ts
import {
capChips, doneStats, backlogSeries, areaPath, laneChips,
} from './viewmodel.js';
/** Static skeleton: four KPI cards; values are filled client-side. */
export function kpiSkeletonHtml(): string {
return `
<section class="b2-kpis" aria-label="Kennzahlen">
<div class="b2-kpi b2-glass" id="kpiOpen">
<div class="b2-lbl">Open</div>
<div class="b2-val"><span data-k="count"></span> <small data-k="total"></small></div>
<div class="b2-miniarea" data-k="area"></div>
</div>
<div class="b2-kpi b2-glass" id="kpiInProgress">
<div class="b2-lbl">In Progress</div>
<div class="b2-val"><span data-k="count"></span></div>
<div class="b2-chips" data-k="chips"></div>
</div>
<div class="b2-kpi b2-glass" id="kpiReview">
<div class="b2-lbl">Review</div>
<div class="b2-val"><span data-k="count"></span></div>
<div class="b2-chips" data-k="chips"></div>
</div>
<div class="b2-kpi b2-glass" id="kpiDone">
<div class="b2-lbl">Done</div>
<div class="b2-val"><span data-k="count"></span> <small data-k="total"></small></div>
<div class="b2-cap-r" data-k="cap"></div>
<div class="b2-pbar"><i data-k="bar"></i></div>
</div>
</section>`;
}
/**
* Inline script: injects the pure viewmodel helpers (toString) and defines
* window.__b2UpdateKpis(tasks, agentColor), called by the data layer on every
* refresh. Agent colors reuse the existing per-agent palette via the passed
* agentColor(name) function from the columns module.
*/
export function kpiJs(): string {
return `
${capChips.toString()}
${doneStats.toString()}
${backlogSeries.toString()}
${areaPath.toString()}
${laneChips.toString()}
window.__b2UpdateKpis = function (tasks, agentColor) {
var DAY = 86400000;
function setK(card, key, fn) {
var el = document.querySelector('#' + card + ' [data-k="' + key + '"]');
if (el) fn(el);
}
// Open
var open = tasks.filter(function (t) { return t.status === 'open'; });
setK('kpiOpen', 'count', function (el) { el.textContent = String(open.length); });
setK('kpiOpen', 'total', function (el) {
el.textContent = '/ ' + tasks.filter(function (t) { return t.status !== 'cancelled'; }).length;
});
setK('kpiOpen', 'area', function (el) {
var p = areaPath(backlogSeries(tasks, 14), 120, 26);
el.innerHTML = '<svg width="100%" height="26" viewBox="0 0 120 26" preserveAspectRatio="none">'
+ '<path fill="rgba(148,163,184,.18)" d="' + p.area + '"/>'
+ '<path class="b2-spark" fill="none" stroke="#94a3b8" stroke-width="1.5" d="' + p.line + '"/></svg>';
});
// In Progress + Review chips (max 3, then +n)
['in_progress', 'review'].forEach(function (status) {
var card = status === 'review' ? 'kpiReview' : 'kpiInProgress';
var chips = laneChips(tasks, status);
var capped = capChips(chips, 3);
setK(card, 'count', function (el) { el.textContent = String(chips.length); });
setK(card, 'chips', function (el) {
el.innerHTML = capped.visible.map(function (c) {
var color = agentColor ? agentColor(c.name) : '#a5b4fc';
var initial = c.name.charAt(0).toUpperCase();
var time = c.minutes == null ? '' : (status === 'review' ? 'prüft ' : '') + c.minutes + 'm';
var dot = status === 'in_progress' ? '<span class="b2-cdot"></span>' : '';
return '<span class="b2-achip"><i style="background:' + color + '">' + initial + '</i>' + dot + time + '</span>';
}).join('') + (capped.hidden > 0 ? '<span class="b2-achip b2-more">+' + capped.hidden + '</span>' : '');
});
});
// Done
var ds = doneStats(tasks);
setK('kpiDone', 'count', function (el) { el.textContent = String(ds.done); });
setK('kpiDone', 'total', function (el) { el.textContent = '/ ' + ds.total; });
setK('kpiDone', 'cap', function (el) {
el.textContent = ds.pct + '% · +' + ds.doneThisWeek + ' diese Woche';
});
setK('kpiDone', 'bar', function (el) { el.style.width = ds.pct + '%'; });
};`;
}
```
- [ ] **Step 3: Tests grün**
Run: `npx vitest run tests/boardV2-kpis.test.ts` — Expected: PASS (2 Tests).
- [ ] **Step 4: Commit**
```bash
git add src/server/board/kpis.ts tests/boardV2-kpis.test.ts
git commit -m "feat(board): v2 kpi cards (area chart, capped chips, done bar)"
```
---
### Task 5: Sidebar-Modul (Budget-Tabs, Donuts, Verlauf-Chart, Live-Feed)
**Files:**
- Create: `src/server/board/sidebar.ts`
- Reference (zu portierende v1-Logik): `src/server/board.ts:1548-1749` (`renderBudget`, `renderDonut`, `renderAgentBars`, `writeBaseline`, Session/Total, `data-donut-tab`, Reset-Handler)
- Test: `tests/boardV2-sidebar.test.ts`
- [ ] **Step 1: Failing Test**
`tests/boardV2-sidebar.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { sidebarHtml, sidebarJs } from '../src/server/board/sidebar.js';
describe('sidebarHtml', () => {
it('renders budget card with über-tabs and insight as default', () => {
const h = sidebarHtml();
expect(h).toContain('id="b2Budget"');
expect(h).toContain('data-otab="insight"');
expect(h).toContain('data-otab="verlauf"');
expect(h).toContain('id="budgetReset"');
expect(h).toContain('id="b2Feed"');
});
});
describe('sidebarJs', () => {
it('persists the über-tab, keeps v1 reset behaviour and renders throughput', () => {
const js = sidebarJs();
expect(js).toContain('agenthub-budget-otab'); // localStorage key
expect(js).toContain('writeBaseline'); // v1 reset port
expect(js).toContain('function throughputSeries');
expect(js).toContain('__b2UpdateSidebar');
});
});
```
Run: `npx vitest run tests/boardV2-sidebar.test.ts` — Expected: FAIL.
- [ ] **Step 2: `src/server/board/sidebar.ts` implementieren**
```ts
import { throughputSeries, areaPath } from './viewmodel.js';
/**
* Sidebar skeleton: budget card (über-tabs Token Insights | Verlauf; insight is
* the default) + live feed card. Donut/bars markup is rendered client-side by
* the ported v1 renderBudget() — the skeleton only provides the mount points.
*/
export function sidebarHtml(): string {
return `
<aside class="b2-side">
<div class="b2-panel b2-glass" id="b2Budget">
<div class="b2-tabhead">
<h6>Budget</h6>
<div class="b2-otabs">
<button type="button" data-otab="insight" class="active">Token Insights</button>
<button type="button" data-otab="verlauf">Verlauf</button>
</div>
</div>
<div data-opane="insight">
<div class="b2-subhead">
<div class="b2-stabs" id="donutTabs"></div>
<div class="seg" id="budgetSeg"></div>
<button type="button" class="reset-btn" id="budgetReset">Reset</button>
</div>
<div id="budgetBody"></div>
</div>
<div data-opane="verlauf" hidden>
<div id="b2Throughput"></div>
<div class="b2-chart-tip" id="b2ThroughputTip"></div>
</div>
</div>
<div class="b2-panel b2-glass">
<h6>Live</h6>
<div class="b2-feed" id="b2Feed"></div>
</div>
</aside>`;
}
/**
* Inline script: über-tab switching (persisted in localStorage), throughput
* area chart, live feed, plus the PORTED v1 budget code (donut, agent bars,
* session/total, baseline reset) — see plan Task 5 Step 3 for the port source.
*/
export function sidebarJs(v1BudgetJs: string): string {
return `
${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 = '<svg width="100%" height="80" viewBox="0 0 220 80" preserveAspectRatio="none">'
+ '<path fill="rgba(56,189,248,.25)" d="' + p.area + '"/>'
+ '<path class="b2-spark" fill="none" stroke="#38bdf8" stroke-width="1.6" d="' + p.line + '"/></svg>';
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}`;
}
```
- [ ] **Step 3: v1-Budget-JS portieren**
Aus `src/server/board.ts:1548-1749` die Funktionen `renderBudget`, `renderDonut`, `renderAgentBars`, `writeBaseline`, die Session/Total-Logik (`budgetMode`, localStorage `agenthub-budget-mode`), die Sub-Tab-Logik (`data-donut-tab`, localStorage `agenthub-donut-metric`) und den Reset-Handler (`board.ts:1739-1749`) unverändert in einen String-Block übernehmen und als `v1BudgetJs` an `sidebarJs()` übergeben (beim Assembly in Task 6). Einzige Anpassung: das v1-Markup mountet jetzt in `#budgetBody` / `#donutTabs` / `#budgetSeg` statt der alten Container-IDs; die Sub-Tabs bekommen die Labels „Token" / „Kosten". Donut-Center-Text (Fix aus v1, SVG `<text>` bei cx=110 cy=122) beibehalten.
- [ ] **Step 4: Tests grün**
Run: `npx vitest run tests/boardV2-sidebar.test.ts` — Expected: PASS (2 Tests).
- [ ] **Step 5: Commit**
```bash
git add src/server/board/sidebar.ts tests/boardV2-sidebar.test.ts
git commit -m "feat(board): v2 sidebar with tabbed budget card, throughput chart, live feed"
```
---
### Task 6: Assembly + Spalten/Karten/Modals-Port + Verdrahtung
**Files:**
- Create: `src/server/board/index.ts`
- Modify: `src/server/routes.ts:16` (Import auf `./board/index.js`)
- Delete: `src/server/board.ts` (nach erfolgreichem Port)
- Reference (Port-Quelle): `src/server/board.ts` komplett — insb. `847-973` (Skeleton), `975-2094` (JS: `taskCard()` ~1123, `refresh()` ~1373, Drag&Drop, SSE `task-log`, Modals, Toasts, `metric-flash`, Lottie-Init 2034-2094)
- [ ] **Step 1: Failing Test**
`tests/boardV2.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { renderBoardHtml } from '../src/server/board/index.js';
describe('renderBoardHtml (v2)', () => {
const html = renderBoardHtml('demo-project');
it('is a full html document with favicon and splash', () => {
expect(html).toMatch(/^<!doctype html>/i);
expect(html).toContain('rel="icon" href="/logo.svg"');
expect(html).toContain('id="b2-splash"');
});
it('contains header, kpis, columns and sidebar mount points', () => {
for (const s of ['b2-hdr', 'demo-project', 'id="kpiOpen"', 'id="b2Budget"', 'id="b2Feed"']) {
expect(html).toContain(s);
}
});
it('keeps the v1 interaction surface (dnd, sse, modals, budget reset)', () => {
for (const s of ['draggable', "new EventSource('/events')", 'task-log', 'budgetReset', 'newTaskModal']) {
expect(html).toContain(s);
}
});
it('does not ship the old v1 metric cards or lottie kpi icons', () => {
expect(html).not.toContain('metric-card');
});
});
```
Run: `npx vitest run tests/boardV2.test.ts` — Expected: FAIL (Modul fehlt).
- [ ] **Step 2: `src/server/board/index.ts` implementieren**
Struktur (die Lücken `columnsJs()`/`modalsJs()` kommen aus Step 3):
```ts
import { boardV2Css } from './styles.js';
import { headerHtml, splashHtml, splashJs } from './chrome.js';
import { kpiSkeletonHtml, kpiJs } from './kpis.js';
import { sidebarHtml, sidebarJs } from './sidebar.js';
import { columnsHtml, columnsJs, modalsHtml, modalsJs, dataLayerJs } from './columns.js';
import { v1BudgetJs } from './v1Budget.js';
export const BOARD_COLUMNS = [
{ key: 'open', label: 'Open' },
{ key: 'in_progress', label: 'In Progress' },
{ key: 'review', label: 'Review' },
];
export function renderBoardHtml(projectName = 'AgentHub Project'): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>agenthub — Board</title>
<link rel="icon" href="/logo.svg" type="image/svg+xml">
<style>${boardV2Css()}</style>
</head>
<body class="b2">
${splashHtml()}
${headerHtml(projectName)}
<main class="b2-body">
<div class="b2-main">
${kpiSkeletonHtml()}
${columnsHtml(BOARD_COLUMNS)}
</div>
${sidebarHtml()}
</main>
${modalsHtml()}
<div id="toasts"></div>
<script>${splashJs()}</script>
<script>${dataLayerJs()}${kpiJs()}${sidebarJs(v1BudgetJs)}${columnsJs()}${modalsJs()}</script>
</body>
</html>`;
}
```
- [ ] **Step 3: `columns.ts` + `v1Budget.ts` aus dem alten board.ts portieren**
- `src/server/board/v1Budget.ts`: `export const v1BudgetJs = \`...\`` mit dem Budget-Block aus Task 5 Step 3.
- `src/server/board/columns.ts` exportiert `columnsHtml(columns)` (3 Spalten-Skeleton, Glass-Panels, Status-Punkt + Count-Pill — Markup aus Mockup v2.5), `columnsJs()` (Port aus altem `board.ts`: `taskCard()` ~1123 inkl. Live-Konsole/Timer/Agent-Tag/Reviewer-Badge, Drag&Drop mit Race-Guard-Revert, Agent-Chip-Drop, `metric-flash`-Wertänderungspuls — **nicht** die Lottie-KPI-Icons, die entfallen), `modalsHtml()`/`modalsJs()` (New-Task-, Delete-, Task-Detail-Modal, unveränderte Logik), `dataLayerJs()` (`refresh()` ~1373: fetch `/tasks`+`/agents`, SSE `/events`, SSE `task-log`; nach jedem erfolgreichen `refresh()` Aufruf von `window.__b2UpdateKpis(tasks, agentColor)`, `window.__b2RenderThroughput(tasks)` und einmalig `window.__b2SplashDone()`; SSE-Events zusätzlich an `window.__b2FeedPush(...)` als kompakte einzeilige Meldung, z.B. `TSK-0145 → in_progress`).
- Karten-Enter-Animation: wie v1 nur für `.card-new` (Kommentar im alten Code ~663-666 beachten), damit Poll-Updates nicht flackern.
- `agentColor(name)`: bestehende Agenten-Farbpalette aus dem alten board.ts übernehmen (claude `#D97757`, codex `#10A37F` etc.) und dem KPI-Modul als Funktion bereitstellen.
- [ ] **Step 4: Verdrahtung + altes board.ts löschen**
1. `src/server/routes.ts:16`: `import { renderBoardHtml } from './board.js';``import { renderBoardHtml } from './board/index.js';`
2. `src/server/board.ts` löschen.
3. Prüfen, dass keine andere Datei mehr `./board.js` (Datei) importiert: `grep -rn "from '.*board\.js'" src tests` — erwartete Treffer nur `board/index.js` und `board/*.js`-interne Importe.
- [ ] **Step 5: Build + volle Testsuite**
Run: `npx tsc --noEmit` — Expected: keine Fehler.
Run: `npx vitest run` — Expected: gesamte Suite grün (inkl. `tests/boardV2*.test.ts`; bestehende Tests, die v1-board-Strings assertieren, sind ggf. an die neuen Mount-Points anzupassen — nur die Assertions aktualisieren, keine Testlogik ändern).
- [ ] **Step 6: Manueller Check**
```bash
pnpm build 2>/dev/null || npx tsc
node bin/agenthub.js server start
# Browser: http://127.0.0.1:3377/board
```
Checkliste: Splash erscheint und faded aus · KPI-Karten füllen sich (Chips max 3 + „+n") · Open-Area-Chart zeichnet sich · Done-Bar animiert · Über-Tabs (Token Insights ↔ Verlauf) + Sub-Tabs (Token ↔ Kosten) + Session/Total + Reset funktionieren wie v1 · Drag&Drop Statuswechsel · Agent-Zuweisung per Drop · Live-Konsole läuft · Live-Feed zeigt Events · SSE-Dot grün · `prefers-reduced-motion` (DevTools-Emulation) schaltet Animationen ab.
- [ ] **Step 7: Commit**
```bash
git add src/server/board/ src/server/routes.ts tests/boardV2.test.ts
git rm src/server/board.ts 2>/dev/null || true
git commit -m "feat(board): replace v1 monolith with modular v2 glass dashboard"
```
---
## Self-Review (ausgeführt)
- **Spec-Abdeckung:** Logo (T1/T3), Splash (T3), Header m. Projektzelle (T3), KPIs inkl. aller 4 Karten-Designs (T1+T4), Spalten/Karten/Drag&Drop/Konsole/Modals (T6), Sidebar-Tabs inkl. Reset + Session/Total + Sub-Tabs (T5), Verlauf-Chart (T5, als Tasks/Tag — `/budget` hat verifiziert keine Historie; Spec-Fallback greift), Live-Feed (T5/T6), Modularisierung (T2-T6), reduced-motion (T2), Lottie-Entfall dokumentiert (T6 Step 1 Test + Step 3), Multi-Projekt nur visuell (T3, v3-Spec). Keine Lücken.
- **Placeholder-Scan:** Task 2 Step 2 und Task 6 Step 3 sind bewusst Port-Anweisungen mit exakten Quell-Referenzen (Datei + Zeilen) statt dupliziertem Code — der Code existiert bereits in `src/server/board.ts` bzw. im Mockup; keine TBDs.
- **Typ-Konsistenz:** `capChips`, `doneStats`, `backlogSeries`, `throughputSeries`, `areaPath`, `laneChips`, `KpiTask`, `AgentChip` in T1 definiert und in T4/T5 identisch verwendet; `window.__b2UpdateKpis` / `__b2RenderThroughput` / `__b2FeedPush` / `__b2SplashDone` konsistent zwischen T3/T4/T5/T6.

View File

@ -1,104 +0,0 @@
# AgentHub Board v2 — Redesign (Spec)
Datum: 2026-07-20
Status: freigegeben durch User (Mockup-Iterationen v2 → v2.5 im Visual Companion)
## Ziel
Die Board-Seite (`/board`) sieht aus wie ein echtes, modernes Dashboard: Glas-Optik (Richtung „Modern SaaS / Glass"), dezente Animationen, mehr Daten-Leben (Charts, Live-Feed), Logo + Splash Screen. **Alle vorhandenen Funktionen bleiben erhalten** (KPIs, Drag & Drop, Agent-Zuweisung, Live-Konsole in Karten, Modals, Token-Budget, SSE). Die Team-Seite bleibt unverändert.
## Nicht-Ziele (Out of Scope)
- Multi-Projekt-Architektur (Projekt anlegen/wechseln per Dropdown) → separater v3-Spec. In v2 nur visuell vorbereitet: Projektname im Header mit Chevron (`▾`), ohne Funktion.
- Verlaufs-Charts auf der Activity-Page (später).
- Kein Frontend-Framework (bewusste Entscheidung: Fastify + String-Templates + Vanilla JS + SSE, kein Build-Step).
## Architektur-Entscheidung
`src/server/board.ts` (aktuell ~2100 Zeilen, monolithisch, nutzt `ui-shared.ts` nicht) wird in Module zerlegt, ohne das Serving-Modell zu ändern (Fastify rendert HTML-String beim Start, Daten via `/tasks`, `/agents`, `/budget`, SSE `/events`):
```
src/server/board/
index.ts — renderBoardHtml(projectName), orchestriert Module
styles.ts — v2 Design-Tokens + Glass-CSS + Animations-Keyframes
header.ts — Header (Logo, Projektzelle mit Chevron, Nav, New-Task, SSE-Dot)
kpis.ts — KPI-Reihe (Markup + Update-JS)
columns.ts — Spalten + Task-Karten + Drag&Drop + Live-Konsole
sidebar.ts — Budget-Card (Tabs) + Live-Feed
splash.ts — Splash-Screen (Markup + CSS + JS)
modals.ts — New-Task / Delete / Task-Detail (unveränderte Logik, neues Styling)
assets/
logo.svg — AgentHub-Logo (Hexagon-Gradient, Cyan #38bdf8 → Violett #8b5cf6)
```
Design-Tokens werden an `ui-shared.ts` angeglichen (gleiche Farbwerte, Glass-Surface als `--surface` etc.), damit Board und die übrigen Seiten konsistent bleiben. Team-Page wird nicht angefasst.
## Layout (wie Mockup v2.5)
Dunkler Gradient-Hintergrund (`radial-gradient`, #1c2547#0e1226#090c18). Glass-Surfaces: `rgba(255,255,255,.045)`, Border `rgba(255,255,255,.09)`, Radius 12px.
### Header
- Logo (neu, SVG, Hexagon-Gradient), `agenthub` Wortmarke
- Projektzelle `/ <projektname> ▾` (nur Optik; Dropdown-Funktion kommt mit v3)
- Nav: Board (aktiv) / Team / Activity / Decisions
- Rechts: `+ New task` Button (Indigo→Violett-Gradient), SSE-Live-Dot (grün, ping-Animation)
### KPI-Reihe (4 Karten, nur über dem Board-Bereich, nicht über Sidebar)
1. **Open** — Zahl `/ gesamt`, Mini-Area-Chart (Backlog-Verlauf, 14 Tage, aus `/tasks` abgeleitet), Grau
2. **In Progress** — Zahl, Agent-Chips: max. 3 sichtbar, dann `+n`-Chip; jeder Chip: Avatar (Agent-Farbe), Live-Punkt (pulsierend), Laufzeit
3. **Review** — Zahl, gleiche Chip-Darstellung (Reviewer), max. 3 + `+n`
4. **Done** — Zahl `/ gesamt`, Progress-Bar (Anteil erledigt, Grün-Gradient, animierter Fill), Beschreibung („63% · +5 diese Woche") klein rechtsbündig direkt über dem Bar-Ende
Karten erscheinen gestaffelt (rise-Animation, 70ms Versatz).
### Board-Spalten (unveränderte Funktion)
3 Spalten (Open / In Progress / Review) als Glass-Panels, Header mit Status-Punkt + Count-Pill. Task-Karten: Glass, Hover-Lift, Status-Farbe am linken Rand (dezent), Agent-Tag, Reviewer-Badge, Timer, Glow (`rgba(56,189,248,.35)` Border + Shadow) auf aktiv bearbeiteten Karten. **In-Progress-Karten zeigen einen Progress-Ring** (SVG-Radial, Füllung = verstrichene Zeit / geschätzte Dauer, Mitte = tickende verstrichene Zeit). Schätzung = Median der realen Bearbeitungsdauer der letzten erledigten Tasks (`claimedAt``updatedAt`, clientseitig aus `/tasks`); Fallback = Zeit-Kappe analog `MAX_LIVE_ESTIMATE_MIN_PER_TASK` aus `budgetService`. Über der Schätzung kippt der Ring von Cyan nach Amber. (Änderung gegenüber Mockup: ersetzt den dortigen Fantasie-Prozentbalken — ein echter Prozentwert existiert in den Daten nicht.) Drag&Drop (Status) + Agent-Chip-Drop (Zuweisung) + Live-Konsole bleiben funktional identisch.
### Sidebar rechts
**Budget-Card** mit zwei Ebenen, immer genau ein Chart sichtbar:
- Über-Tabs: `Token Insights` (Default) | `Verlauf`
- Token Insights: Sub-Tabs `Token` | `Kosten`, Session/Total-Segmented-Control und **Reset-Button** wie v1
- Token: Half-Donut (animierter Arc) + Per-Agent-Bars (Agent-Farben, animierter Fill)
- Kosten: Half-Donut + Firmen-Legende (Anthropic/OpenAI/Moonshot mit Farbpunkten)
- Verlauf: Area-Chart (Tokens, 14 Tage) mit Sollwert-Zeile („Ø x/Tag")
- Tab-Persistenz in `localStorage`
**Live-Feed-Card** darunter: letzte ~5 Events aus SSE `/events` (kompakte Zeilen, pulsierender Punkt beim neuesten).
### Splash Screen
Beim Laden von `/board`: Fullscreen-Overlay im Hintergrund-Gradient, zentriertes Logo mit Einblend-/Puls-Animation + „agenthub" Schriftzug; nach DOMContentLoaded + erstem Daten-Fetch (oder max. ~1,2s) smooth ausfaden und Dashboard einblenden. Kein Blockieren der eigentlichen Daten-Requests.
### Logo
`assets/logo.svg`: Hexagon mit Cyan→Violett-Verlauf, ausgestanzter Kern (Knoten-Metapher, passt zum Agenten-Netzwerk). Verwendet in Header, Splash, Favicon.
## Animationen (dezent, respektiert `prefers-reduced-motion`)
- `rise` (opacity + translateY 8px, 0,50,6s) für Karten beim ersten Render — nicht bei SSE-/Poll-Updates (wie bisher nur `.card-new`)
- `fill` (width 0 → Ziel) für Bars, `draw` (stroke-dashoffset) für Charts/Arcs
- `ping` für Live-Dot, `blink` für Status-Punkte
- KPI-Wertänderung: kurzer Flash (bestehendes `metric-flash`-Verhalten beibehalten)
- Keine Animation bei `prefers-reduced-motion: reduce`
## Datenfluss
Unverändert: initiales `refresh()` (`GET /tasks`, `/agents`, `/budget`), SSE `/events` für Live-Updates, `task-log` SSE für Karten-Konsole, 3s-Polling für Budget. Neu abgeleitet aus vorhandenen Daten:
- Backlog-Mini-Area (Open): Open-Count pro Tag der letzten 14 Tage aus Task-`createdAt`/Status-Historie — falls nicht aus den vorhandenen Endpunkten ableitbar, aus `/tasks` (created/status/updated) clientseitig approximiert; kein neuer Endpunkt in v2 (Fallback: flache Linie mit aktuellem Wert).
- Verlauf-Area-Chart (Tokens/Tag): aus `/budget`-Historie, falls vorhanden; sonst gleiche Fallback-Strategie. Implementierung klärt, was `/budget` tatsächlich liefert.
- „+5 diese Woche" bei Done: aus `/tasks` (done + `updatedAt` in den letzten 7 Tagen).
## Fehlerbehandlung
- SSE-Reconnect-Verhalten wie v1 (Verbindungs-Dot im Header).
- Wenn Budget-Endpunkt leer/fehlschlägt: Sidebar-Panels zeigen vorhandene v1-Leerzustände.
- Splash hat harte Obergrenze (~1,5s), damit er nie blockiert.
## Testing
- Bestehende Tests (`tests/`) müssen grün bleiben (Board-Rendering ist String-basiert; Snapshot-/String-Assertions ggf. anpassen).
- Neue Unit-Tests für: `kpis.ts` (Chip-Kappung 3+`+n`, Prozent-Berechnung Done, „+diese Woche"), `sidebar.ts` (Tab-Default, Reset-Button-Markup), `splash.ts` (Overlay-Markup, reduced-motion Guard), Logo-Einbettung im Header.
- Manueller Check: `agenthub server start``/board` im Browser (Splash, Tabs, Drag&Drop, Live-Updates).
## Entscheidungen (Log aus dem Brainstorming)
- Design-Richtung: „Glass / Modern SaaS" (Basis B), kein Nuxt/Vue (Overkill, widerspricht Simplicity-Ziel)
- Active/Open zu einer Karte „Open" verschmolzen (Kanban-Struktur: Open / In Progress / Review / Done)
- Sparklines verworfen zugunsten: Mini-Area (Open), Progress-Bar (Done), Chips (In Progress/Review)
- Chips statt Avatar-Stacks, gekappt bei 3 + `+n`
- Verschachtelte Tabs rechts (Token Insights default), Reset-Button wie v1
- Verlaufs-Charts später ggf. auf Activity-Page
- Multi-Projekt = v3 (eigener Spec)

View File

@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "0.10.0", "version": "0.9.1",
"description": "Local coordination layer for AI coding agents", "description": "Local coordination layer for AI coding agents",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",

View File

@ -1,114 +0,0 @@
import { createAsk, listAsks, answerAsk, escalateAsk } from '../../core/services/askService.js';
import { parseSSEBuffer } from './watch.js';
import { remoteClient } from '../remoteClient.js';
import type { Ask } from '../../core/schema.js';
export function askCreate(cwd: string, opts: { from: string; question: string; taskId?: string }): Ask {
const a = createAsk(cwd, { from: opts.from, question: opts.question, taskId: opts.taskId });
console.log(`AgentHub: Ask sent ${a.id} (${a.from}${a.to})${a.taskId ? ` [${a.taskId}]` : ''}: ${a.question}`);
return a;
}
export function askList(cwd: string, opts: { pending?: boolean } = {}): void {
const asks = listAsks(cwd, opts.pending ? { status: 'pending' } : {});
if (asks.length === 0) {
console.log('No asks.');
return;
}
for (const a of asks) {
const flag = a.status === 'pending' ? '●' : ' ';
let line = `${flag} ${a.id} ${a.from}${a.to} [${a.status}]${a.taskId ? ` (${a.taskId})` : ''}: ${a.question}`;
if (a.answer) line += `\n ↳ ${a.answeredBy ?? a.to}: ${a.answer}`;
if (a.status === 'escalated') line += `\n ↳ escalated to ${a.escalatedTo ?? 'ceo'}`;
console.log(line);
}
}
export function askAnswer(cwd: string, id: string, text: string, by?: string): void {
const a = answerAsk(cwd, id, text, by);
console.log(`AgentHub: Ask answered ${a.id}${by ? ` by ${by}` : ''}`);
}
export function askEscalate(cwd: string, id: string, note?: string): void {
const a = escalateAsk(cwd, id, note);
console.log(`AgentHub: Ask escalated ${a.id}${a.escalatedTo ?? 'ceo'}`);
}
/**
* Block on the SSE stream until the Ask leaves `pending` (answered/escalated),
* or the timeout elapses. Mirrors the work-loop wait (reconnect-friendly) but
* wakes on `ask` events. Resolves with the settled Ask, or null on timeout.
*/
export function waitForAsk(serverUrl: string, askId: string, timeoutSec?: number): Promise<Ask | null> {
return new Promise((resolve) => {
let settled = false;
let controller: AbortController | undefined;
const finish = (v: Ask | null) => {
if (settled) return;
settled = true;
try {
controller?.abort();
} catch {
/* already aborted */
}
resolve(v);
};
const deadline = timeoutSec ? Date.now() + timeoutSec * 1000 : undefined;
const timer = timeoutSec ? setTimeout(() => finish(null), timeoutSec * 1000) : undefined;
const check = async (): Promise<boolean> => {
try {
const { ask } = await remoteClient.getAsk(serverUrl, askId);
if (ask.status !== 'pending') {
if (timer) clearTimeout(timer);
finish(ask);
return true;
}
} catch {
/* transient — keep listening */
}
return false;
};
const loop = async () => {
while (!settled && (deadline === undefined || Date.now() < deadline)) {
controller = new AbortController();
try {
const res = await fetch(`${serverUrl}/events`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } });
if (!res.body) throw new Error('SSE response has no body');
if (await check()) return; // close the gap after subscribing
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (!settled) {
let done: boolean;
let value: Uint8Array | undefined;
try {
({ done, value } = await reader.read());
} catch {
break;
}
if (done) break;
if (value) buffer += decoder.decode(value, { stream: true });
const { events, remaining } = parseSSEBuffer(buffer);
buffer = remaining;
if (events.some((e) => e.type === 'ask')) {
if (await check()) return;
}
}
} catch (err: unknown) {
if (settled || (err instanceof Error && err.name === 'AbortError')) return;
}
if (settled) break;
await new Promise((r) => setTimeout(r, 1000));
}
if (timer) clearTimeout(timer);
finish(null);
};
loop().catch(() => {
if (timer) clearTimeout(timer);
finish(null);
});
});
}

View File

@ -1,5 +1,4 @@
import { createMessage, listInbox, markMessageRead, ackMessage, getMessage } from '../../core/services/messageService.js'; import { createMessage, listInbox } from '../../core/services/messageService.js';
import type { Message } from '../../core/schema.js';
export function messageSend( export function messageSend(
cwd: string, cwd: string,
@ -20,41 +19,3 @@ export function inboxList(cwd: string, opts: { agent: string; unreadOnly?: boole
console.log(`${flag} ${m.id} ${m.from}${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`); console.log(`${flag} ${m.id} ${m.from}${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
} }
} }
export function messageRead(cwd: string, id: string): void {
const m = markMessageRead(cwd, id);
console.log(`AgentHub: Message read ${m.id} (${m.from}${m.to})`);
}
export function inboxMarkRead(cwd: string, opts: { agent: string; unreadOnly?: boolean }): void {
const msgs = listInbox(cwd, opts.agent, { unreadOnly: opts.unreadOnly });
for (const m of msgs) markMessageRead(cwd, m.id);
console.log(`AgentHub: marked ${msgs.length} message${msgs.length === 1 ? '' : 's'} read for ${opts.agent}`);
}
export function messageAck(cwd: string, id: string, by?: string): void {
const m = ackMessage(cwd, id, by);
console.log(`AgentHub: Message acked ${m.id} (${m.from}${m.to})${by ? ` by ${by}` : ''}`);
}
/**
* Reply to a message: loads the parent, sends a new message back to the parent's
* sender (to = parent.from), links it via replyTo, and inherits the parent's
* taskId unless one is given.
*/
export function messageReply(
cwd: string,
parentId: string,
opts: { from: string; text: string; taskId?: string },
): Message {
const { message: parent } = getMessage(cwd, parentId);
const m = createMessage(cwd, {
from: opts.from,
to: parent.from,
text: opts.text,
taskId: opts.taskId ?? parent.taskId,
replyTo: parentId,
});
console.log(`AgentHub: Reply sent ${m.id} (${m.from}${m.to}) ↩ ${parentId}`);
return m;
}

View File

@ -1,6 +1,5 @@
import { input, select } from '@inquirer/prompts'; import { input, select } from '@inquirer/prompts';
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js'; import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js';
import { appendTaskLog } from '../../core/services/taskLogService.js';
import type { Task } from '../../core/schema.js'; import type { Task } from '../../core/schema.js';
export async function taskCreate(cwd: string, options: Partial<Task> = {}): Promise<void> { export async function taskCreate(cwd: string, options: Partial<Task> = {}): Promise<void> {
@ -70,12 +69,3 @@ export function taskAssign(cwd: string, id: string, agentName: string): void {
assignTask(cwd, id, agentName); assignTask(cwd, id, agentName);
console.log(`AgentHub: Task assigned ${id}${agentName}`); console.log(`AgentHub: Task assigned ${id}${agentName}`);
} }
export function taskLog(
cwd: string,
id: string,
entry: { text: string; agent?: string; level?: string },
): void {
const rec = appendTaskLog(cwd, id, entry);
console.log(`AgentHub: logged ${id} ${rec.text}`);
}

View File

@ -15,7 +15,6 @@
*/ */
import type { AgentHubEvent } from '../../server/events.js'; import type { AgentHubEvent } from '../../server/events.js';
import { messageRecipientAliases } from '../../core/services/messageService.js';
// Re-export so tests can import type + helpers from one place. // Re-export so tests can import type + helpers from one place.
export type { AgentHubEvent } from '../../server/events.js'; export type { AgentHubEvent } from '../../server/events.js';
@ -135,36 +134,6 @@ async function fetchReviewTasks(serverUrl: string): Promise<AgentHubEvent[]> {
} }
} }
/** Fetch unread messages currently addressed to an agent or its role alias. */
async function fetchUnreadMessages(serverUrl: string, agent: string): Promise<AgentHubEvent[]> {
try {
const res = await fetch(new URL(`/messages?agent=${encodeURIComponent(agent)}&unread=1`, serverUrl).toString());
if (!res.ok) return [];
const messages = (await res.json()) as Array<{
id: string;
from?: string;
to?: string;
status?: string;
}>;
return messages.map((m) => ({
type: 'message',
action: 'created',
id: m.id,
title: `${m.from ?? ''}${m.to ?? ''}`,
status: m.status ?? 'unread',
assignedTo: m.to,
}));
} catch {
return [];
}
}
function isMessageFor(event: AgentHubEvent, agent: string): boolean {
if (event.type !== 'message' || event.action !== 'created') return false;
const to = event.assignedTo;
return !!to && messageRecipientAliases(agent).has(String(to).toLowerCase());
}
/** /**
* Connect to the AgentHub server's SSE endpoint and stream events to stdout. * Connect to the AgentHub server's SSE endpoint and stream events to stdout.
* *
@ -179,7 +148,7 @@ function isMessageFor(event: AgentHubEvent, agent: string): boolean {
*/ */
export async function watchEvents( export async function watchEvents(
serverUrl: string, serverUrl: string,
options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; newOnly?: boolean } = {}, options: { once?: boolean; role?: string; awaitReview?: boolean; newOnly?: boolean } = {},
): Promise<void> { ): Promise<void> {
const url = new URL('/events', serverUrl); const url = new URL('/events', serverUrl);
// Pass role to the server for an additional server-side filter (saves // Pass role to the server for an additional server-side filter (saves
@ -223,14 +192,6 @@ export async function watchEvents(
return; return;
} }
} }
if (options.awaitMessage && !options.newOnly) {
const pending = await fetchUnreadMessages(serverUrl, options.awaitMessage);
if (pending.length > 0) {
for (const ev of pending) console.log(formatEvent(ev));
await reader.cancel();
return;
}
}
while (true) { while (true) {
let done: boolean; let done: boolean;
@ -269,10 +230,6 @@ export async function watchEvents(
await reader.cancel(); await reader.cancel();
return; return;
} }
if (options.awaitMessage && isMessageFor(event, options.awaitMessage)) {
await reader.cancel();
return;
}
} }
} }
} }

View File

@ -1,7 +1,5 @@
import { parseSSEBuffer } from './watch.js'; import { parseSSEBuffer } from './watch.js';
import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentContext } from './start.js'; import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentContext } from './start.js';
import { discoverServer as discoverHubServer } from '../../discovery.js';
import { remoteClient } from '../remoteClient.js';
/** /**
* `agenthub work --agent <name> --role <role>` the auto-claim primitive * `agenthub work --agent <name> --role <role>` the auto-claim primitive
@ -15,47 +13,7 @@ import { remoteClient } from '../remoteClient.js';
* up automatically without a human prompt. Best run in the background so the * up automatically without a human prompt. Best run in the background so the
* wait doesn't tie up the foreground. * wait doesn't tie up the foreground.
*/ */
interface WorkAgentContext extends AgentContext { export async function workAgent(ctx: AgentContext & { timeoutSec?: number }): Promise<void> {
timeoutSec?: number;
discoverServer?: (timeoutMs?: number) => Promise<string | undefined>;
reconnectBackoffMs?: number[];
/**
* Unattended mode (TSK-0118): the agent runs without a human at the keyboard.
* It must never pause for human input when it needs a decision it routes an
* `agenthub ask` to the architect and awaits the answer, instead of stalling.
*/
unattended?: boolean;
}
/**
* Fetch + print + mark-read the agent's unread messages. Returns how many were
* surfaced. This is what lets the work loop wake on an architect follow-up /
* question (TSK-0119): after a `task review` submit the implementer re-arms
* `work` and stays reachable a reopen or a new assignment wakes it via a task
* event, and a plain message wakes it here instead of leaving it dormant.
*/
async function drainAgentMessages(ctx: WorkAgentContext): Promise<number> {
if (!ctx.serverUrl) return 0;
let msgs;
try {
msgs = await remoteClient.getInbox(ctx.serverUrl, ctx.agent, true);
} catch {
return 0;
}
if (!msgs.length) return 0;
console.log(`AgentHub: ${msgs.length} message${msgs.length === 1 ? '' : 's'} for ${ctx.agent}:`);
for (const m of msgs) {
console.log(` ${m.id} ${m.from}${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
try {
await remoteClient.markMessageRead(ctx.serverUrl, m.id);
} catch {
/* best-effort */
}
}
return msgs.length;
}
export async function workAgent(ctx: WorkAgentContext): Promise<void> {
await announceAgent(ctx.serverUrl, ctx.agent, ctx.role); await announceAgent(ctx.serverUrl, ctx.agent, ctx.role);
// Already-waiting task? // Already-waiting task?
@ -70,43 +28,22 @@ export async function workAgent(ctx: WorkAgentContext): Promise<void> {
return; return;
} }
// A message may already be waiting (architect followed up while we implemented
// + submitted). Surface it now instead of blocking past a pending question.
if (await drainAgentMessages(ctx)) return;
console.log( console.log(
`AgentHub: waiting for a task or message addressed to ${ctx.agent}${ctx.timeoutSec ? ` (timeout ${ctx.timeoutSec}s)` : ''}`, `AgentHub: waiting for a task addressed to ${ctx.agent}${ctx.timeoutSec ? ` (timeout ${ctx.timeoutSec}s)` : ''}`,
); );
if (ctx.unattended) {
console.log('AgentHub: unattended mode — never pause for human input; route decisions via `agenthub ask` to the architect.');
}
await waitAndClaim(ctx); await waitAndClaim(ctx);
} }
function remainingMs(deadline: number | undefined): number { function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise<void> {
return deadline === undefined ? Number.POSITIVE_INFINITY : Math.max(0, deadline - Date.now()); const serverUrl = ctx.serverUrl as string;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function resolveReconnectUrl(ctx: WorkAgentContext, currentUrl: string): Promise<string> {
if (process.env.AGENTHUB_SERVER) return process.env.AGENTHUB_SERVER;
const discovered = await (ctx.discoverServer ?? discoverHubServer)(2000);
return discovered || currentUrl;
}
function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
let serverUrl = ctx.serverUrl as string;
return new Promise((resolve) => { return new Promise((resolve) => {
const controller = new AbortController();
let settled = false; let settled = false;
let controller: AbortController | undefined;
const finish = () => { const finish = () => {
if (settled) return; if (settled) return;
settled = true; settled = true;
try { try {
controller?.abort(); controller.abort();
} catch { } catch {
/* already aborted */ /* already aborted */
} }
@ -119,14 +56,10 @@ function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
finish(); finish();
}, ctx.timeoutSec * 1000) }, ctx.timeoutSec * 1000)
: undefined; : undefined;
const deadline = ctx.timeoutSec ? Date.now() + ctx.timeoutSec * 1000 : undefined;
const backoffs = ctx.reconnectBackoffMs ?? [2000, 5000, 10000];
let reconnectAttempt = 0;
// Re-query then claim if a task addressed to us is now open. Returns true // Re-query then claim if a task addressed to us is now open. Returns true
// if a task was claimed (so the caller can stop). // if a task was claimed (so the caller can stop).
const tryClaim = async (): Promise<boolean> => { const tryClaim = async (): Promise<boolean> => {
ctx.serverUrl = serverUrl;
const f = await findAddressedOpenTask(ctx); const f = await findAddressedOpenTask(ctx);
if (!f) return false; if (!f) return false;
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
@ -135,80 +68,47 @@ function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
return true; return true;
}; };
// Wake on an architect follow-up message (not just tasks): surface it and fetch(`${serverUrl}/events`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } })
// stop, so the implementer never sits dormant on a pending question. .then(async (res) => {
const trySurfaceMessages = async (): Promise<boolean> => { if (!res.body) {
ctx.serverUrl = serverUrl; if (timer) clearTimeout(timer);
const n = await drainAgentMessages(ctx); finish();
if (n === 0) return false; return;
if (timer) clearTimeout(timer); }
finish(); // Close the gap: a task may have appeared between the initial check and
return true; // this subscription — check once more now that we're listening.
}; if (await tryClaim()) return;
const waitLoop = async () => { const reader = res.body.getReader();
while (!settled && remainingMs(deadline) > 0) { const decoder = new TextDecoder();
controller = new AbortController(); let buffer = '';
try { while (!settled) {
ctx.serverUrl = serverUrl; let done: boolean;
const res = await fetch(`${serverUrl}/events`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } }); let value: Uint8Array | undefined;
if (!res.body) throw new Error('SSE response has no body'); try {
({ done, value } = await reader.read());
// Close the gap: a task or message may have appeared between the initial } catch {
// check and this subscription — check once more now that we're listening. break; // aborted or connection closed
if (await tryClaim()) return;
if (await trySurfaceMessages()) return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
reconnectAttempt = 0;
while (!settled) {
let done: boolean;
let value: Uint8Array | undefined;
try {
({ done, value } = await reader.read());
} catch {
break; // aborted or connection closed
}
if (done) break;
if (value) buffer += decoder.decode(value, { stream: true });
const { events, remaining } = parseSSEBuffer(buffer);
buffer = remaining;
// Any task event may mean a task addressed to us just opened/reopened.
if (events.some((e) => e.type === 'task')) {
if (await tryClaim()) return;
}
// A message event may be an architect follow-up/question for us.
if (events.some((e) => e.type === 'message')) {
if (await trySurfaceMessages()) return;
}
} }
} catch (err: unknown) { if (done) break;
if (settled || (err instanceof Error && err.name === 'AbortError')) return; if (value) buffer += decoder.decode(value, { stream: true });
}
if (settled || remainingMs(deadline) <= 0) break;
serverUrl = await resolveReconnectUrl(ctx, serverUrl);
ctx.serverUrl = serverUrl;
const backoff = backoffs[Math.min(reconnectAttempt, backoffs.length - 1)] ?? 10_000;
reconnectAttempt += 1;
const delay = deadline === undefined ? backoff : Math.min(backoff, remainingMs(deadline));
if (delay > 0) await sleep(delay);
}
if (!settled) {
if (timer) {
clearTimeout(timer);
console.log(`AgentHub: no task for ${ctx.agent} after ${ctx.timeoutSec}s — exiting.`);
}
finish();
}
};
waitLoop().catch((err: unknown) => { const { events, remaining } = parseSSEBuffer(buffer);
console.error(`AgentHub: wait failed: ${err instanceof Error ? err.message : String(err)}`); buffer = remaining;
if (timer) clearTimeout(timer); // Any task event may mean a task addressed to us just opened/reopened.
finish(); if (events.some((e) => e.type === 'task')) {
}); if (await tryClaim()) return;
}
}
if (timer) clearTimeout(timer);
finish();
})
.catch((err: unknown) => {
if (!(err instanceof Error && err.name === 'AbortError')) {
console.error(`AgentHub: wait failed: ${err instanceof Error ? err.message : String(err)}`);
}
if (timer) clearTimeout(timer);
finish();
});
}); });
} }

View File

@ -2,11 +2,10 @@ import { Command } from 'commander';
import { init } from './commands/init.js'; import { init } from './commands/init.js';
import { status } from './commands/status.js'; import { status } from './commands/status.js';
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js'; import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js';
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign, taskLog } from './commands/task.js'; import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign } from './commands/task.js';
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js'; import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
import { decisionCreate, decisionList } from './commands/decision.js'; import { decisionCreate, decisionList } from './commands/decision.js';
import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js'; import { messageSend, inboxList } from './commands/message.js';
import { askCreate, askList, askAnswer, askEscalate, waitForAsk } from './commands/ask.js';
import { agentSetup, hookContext } from './commands/agentSetup.js'; import { agentSetup, hookContext } from './commands/agentSetup.js';
import { syncOrgFromFile } from '../core/services/orgService.js'; import { syncOrgFromFile } from '../core/services/orgService.js';
import { delegate } from './commands/delegate.js'; import { delegate } from './commands/delegate.js';
@ -366,23 +365,6 @@ export function createProgram(cwd: string): Command {
taskReopen(projectCwd, id); taskReopen(projectCwd, id);
} }
}); });
taskCmd
.command('log <id>')
.description('Append a progress line to a task\'s live console (streams to open task-detail pages)')
.requiredOption('--text <text>', 'Progress line')
.option('--agent <agent>', 'Reporting agent')
.option('--level <level>', 'Log level (info | status | warn | error)')
.action(async (id, options: { text: string; agent?: string; level?: string }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
await remoteClient.appendTaskLog(serverUrl, id, { text: options.text, agent: options.agent, level: options.level });
console.log(`AgentHub: logged ${id} ${options.text}`);
});
} else {
taskLog(projectCwd, id, { text: options.text, agent: options.agent, level: options.level });
}
});
program.addCommand(taskCmd); program.addCommand(taskCmd);
const handoffCmd = new Command('handoff').description('Manage handoffs'); const handoffCmd = new Command('handoff').description('Manage handoffs');
@ -475,83 +457,8 @@ export function createProgram(cwd: string): Command {
program.addCommand(decisionCmd); program.addCommand(decisionCmd);
// ─── messaging ─────────────────────────────────────────────────────────── // ─── messaging ───────────────────────────────────────────────────────────
const messageCmd = new Command('message') program
.description('Send and manage direct messages') .command('message <to> <text>')
.argument('[to]', 'Recipient agent/role')
.argument('[text]', 'Message text')
.option('--from <agent>', 'Sender agent name')
.option('--task <id>', 'Related task ID')
.action(async (to: string | undefined, text: string | undefined, options: { from?: string; task?: string }) => {
if (!to || !text || !options.from) {
messageCmd.help({ error: true });
return;
}
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
const payload = { from: options.from, to, text, taskId: options.task };
if (serverUrl) {
await runRemote(serverUrl, async () => {
const m = await remoteClient.sendMessage(serverUrl, payload);
console.log(`AgentHub: Message sent ${m.id} (${m.from}${m.to})`);
});
} else {
messageSend(projectCwd, payload);
}
});
messageCmd
.command('read <id>')
.description('Mark a message as read')
.action(async (id: string) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const m = await remoteClient.markMessageRead(serverUrl, id);
console.log(`AgentHub: Message read ${m.id} (${m.from}${m.to})`);
});
} else {
messageRead(projectCwd, id);
}
});
messageCmd
.command('ack <id>')
.description('Acknowledge a message (strongest read-receipt: you actioned it)')
.option('--by <agent>', 'Agent acknowledging the message')
.action(async (id: string, options: { by?: string }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const m = await remoteClient.ackMessage(serverUrl, id, options.by);
console.log(`AgentHub: Message acked ${m.id} (${m.from}${m.to})${options.by ? ` by ${options.by}` : ''}`);
});
} else {
messageAck(projectCwd, id, options.by);
}
});
messageCmd
.command('reply <parentId>')
.description('Reply to a message: sends back to its sender, links via replyTo, inherits its task')
.requiredOption('--from <agent>', 'Sender agent name')
.requiredOption('--text <text>', 'Reply text')
.option('--task <id>', 'Related task ID (defaults to the parent message\'s task)')
.action(async (parentId: string, options: { from: string; text: string; task?: string }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const { message: parent } = await remoteClient.getMessage(serverUrl, parentId);
const m = await remoteClient.sendMessage(serverUrl, {
from: options.from,
to: parent.from,
text: options.text,
taskId: options.task ?? parent.taskId,
replyTo: parentId,
});
console.log(`AgentHub: Reply sent ${m.id} (${m.from}${m.to}) ↩ ${parentId}`);
});
} else {
messageReply(projectCwd, parentId, { from: options.from, text: options.text, taskId: options.task });
}
});
messageCmd
.command('send <to> <text>')
.description('Send a direct message to another agent') .description('Send a direct message to another agent')
.requiredOption('--from <agent>', 'Sender agent name') .requiredOption('--from <agent>', 'Sender agent name')
.option('--task <id>', 'Related task ID') .option('--task <id>', 'Related task ID')
@ -567,26 +474,14 @@ export function createProgram(cwd: string): Command {
messageSend(projectCwd, payload); messageSend(projectCwd, payload);
} }
}); });
program.addCommand(messageCmd);
program program
.command('inbox') .command('inbox')
.description('Read messages addressed to an agent') .description('Read messages addressed to an agent')
.requiredOption('--agent <agent>', 'Agent whose inbox to read') .requiredOption('--agent <agent>', 'Agent whose inbox to read')
.option('--unread', 'Only unread messages') .option('--unread', 'Only unread messages')
.option('--mark-read', 'Mark listed messages as read') .action(async (options: { agent: string; unread?: boolean }) => {
.option('--wait', 'Wait until a new unread message arrives for this agent')
.action(async (options: { agent: string; unread?: boolean; markRead?: boolean; wait?: boolean }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd); const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (options.wait) {
if (!serverUrl) {
console.error('No AgentHub server found. Start one with: agenthub server start --host 0.0.0.0');
process.exit(1);
return;
}
await watchEvents(serverUrl, { awaitMessage: options.agent, newOnly: true });
return;
}
if (serverUrl) { if (serverUrl) {
await runRemote(serverUrl, async () => { await runRemote(serverUrl, async () => {
const msgs = await remoteClient.getInbox(serverUrl, options.agent, !!options.unread); const msgs = await remoteClient.getInbox(serverUrl, options.agent, !!options.unread);
@ -594,107 +489,12 @@ export function createProgram(cwd: string): Command {
for (const m of msgs) { for (const m of msgs) {
console.log(`${m.status === 'unread' ? '●' : ' '} ${m.id} ${m.from}${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`); console.log(`${m.status === 'unread' ? '●' : ' '} ${m.id} ${m.from}${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
} }
if (options.markRead) {
for (const m of msgs) await remoteClient.markMessageRead(serverUrl, m.id);
console.log(`AgentHub: marked ${msgs.length} message${msgs.length === 1 ? '' : 's'} read for ${options.agent}`);
}
}); });
} else { } else {
if (options.markRead) inboxMarkRead(projectCwd, { agent: options.agent, unreadOnly: !!options.unread }); inboxList(projectCwd, { agent: options.agent, unreadOnly: !!options.unread });
else inboxList(projectCwd, { agent: options.agent, unreadOnly: !!options.unread });
} }
}); });
// ─── asks (autonomous decision-routing) ────────────────────────────────────
const askCmd = new Command('ask')
.description('Ask the architect a blocking question (routes to the architect, never the CEO)')
.argument('[question]', 'Question to route to the architect')
.option('--from <agent>', 'Asking agent')
.option('--task <id>', 'Related task ID')
.option('--wait', 'Block until the architect answers or escalates')
.option('--timeout <sec>', 'With --wait: stop waiting after N seconds')
.action(async (question: string | undefined, options: { from?: string; task?: string; wait?: boolean; timeout?: string }) => {
if (!question || !options.from) {
askCmd.help({ error: true });
return;
}
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
const payload = { from: options.from, question, taskId: options.task };
if (serverUrl) {
await runRemote(serverUrl, async () => {
const a = await remoteClient.createAsk(serverUrl, payload);
console.log(`AgentHub: Ask sent ${a.id} (${a.from}${a.to})${a.taskId ? ` [${a.taskId}]` : ''}: ${a.question}`);
if (options.wait) {
const timeoutSec = options.timeout != null ? Number(options.timeout) : undefined;
console.log(`AgentHub: waiting for an answer to ${a.id}${timeoutSec ? ` (timeout ${timeoutSec}s)` : ''}`);
const answered = await waitForAsk(serverUrl, a.id, timeoutSec);
if (!answered) {
console.log(`AgentHub: no answer to ${a.id}${timeoutSec ? ` within ${timeoutSec}s` : ''} — re-check with: agenthub ask list`);
return;
}
if (answered.status === 'answered') {
console.log(`AgentHub: ${a.id} answered by ${answered.answeredBy ?? answered.to}: ${answered.answer}`);
} else {
console.log(`AgentHub: ${a.id} escalated to ${answered.escalatedTo ?? 'ceo'} — await the CEO decision.`);
}
}
});
} else {
askCreate(projectCwd, payload);
if (options.wait) console.log('AgentHub: --wait needs a running server; the ask was created without waiting.');
}
});
askCmd
.command('list')
.description('List asks (● = pending)')
.option('--pending', 'Only pending asks')
.action(async (options: { pending?: boolean }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const asks = await remoteClient.listAsks(serverUrl, options.pending ? { status: 'pending' } : undefined);
if (asks.length === 0) { console.log('No asks.'); return; }
for (const a of asks) {
console.log(`${a.status === 'pending' ? '●' : ' '} ${a.id} ${a.from}${a.to} [${a.status}]${a.taskId ? ` (${a.taskId})` : ''}: ${a.question}`);
}
});
} else {
askList(projectCwd, { pending: options.pending });
}
});
askCmd
.command('answer <id>')
.description('Answer an ask (architect)')
.requiredOption('--text <text>', 'Answer text')
.option('--by <agent>', 'Answering agent')
.action(async (id: string, options: { text: string; by?: string }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const a = await remoteClient.answerAsk(serverUrl, id, options.text, options.by);
console.log(`AgentHub: Ask answered ${a.id}${options.by ? ` by ${options.by}` : ''}`);
});
} else {
askAnswer(projectCwd, id, options.text, options.by);
}
});
askCmd
.command('escalate <id>')
.description('Escalate an ask to the CEO (architect: release/publish/push, OSS, architecture pivots)')
.option('--note <note>', 'Escalation note')
.action(async (id: string, options: { note?: string }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const a = await remoteClient.escalateAsk(serverUrl, id, options.note);
console.log(`AgentHub: Ask escalated ${a.id}${a.escalatedTo ?? 'ceo'}`);
});
} else {
askEscalate(projectCwd, id, options.note);
}
});
program.addCommand(askCmd);
// ─── auto-start ────────────────────────────────────────────────────────── // ─── auto-start ──────────────────────────────────────────────────────────
const agentCmd = new Command('agent').description('Per-agent machine setup'); const agentCmd = new Command('agent').description('Per-agent machine setup');
agentCmd agentCmd
@ -821,11 +621,10 @@ export function createProgram(cwd: string): Command {
.requiredOption('--agent <name>', 'Agent name') .requiredOption('--agent <name>', 'Agent name')
.option('--role <role>', 'Role (default: implementer)', 'implementer') .option('--role <role>', 'Role (default: implementer)', 'implementer')
.option('--timeout <sec>', 'Stop waiting after N seconds (default: wait indefinitely)') .option('--timeout <sec>', 'Stop waiting after N seconds (default: wait indefinitely)')
.option('--unattended', 'Unattended mode: never pause for human input — route decisions via `agenthub ask`')
.action(async (options) => { .action(async (options) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd); const { serverUrl, projectCwd } = await resolveContext(program, cwd);
const timeoutSec = options.timeout != null ? Number(options.timeout) : undefined; const timeoutSec = options.timeout != null ? Number(options.timeout) : undefined;
const ctx = { serverUrl, projectCwd, agent: options.agent, role: options.role, timeoutSec, unattended: !!options.unattended }; const ctx = { serverUrl, projectCwd, agent: options.agent, role: options.role, timeoutSec };
if (serverUrl) { if (serverUrl) {
await runRemote(serverUrl, () => workAgent(ctx)); await runRemote(serverUrl, () => workAgent(ctx));
} else { } else {
@ -840,8 +639,7 @@ export function createProgram(cwd: string): Command {
.option('--once', 'Exit 0 after the first event (useful as a blocking wait for agents)') .option('--once', 'Exit 0 after the first event (useful as a blocking wait for agents)')
.option('--role <role>', 'Client-side role filter (only show events for this role)') .option('--role <role>', 'Client-side role filter (only show events for this role)')
.option('--await-review', 'Exit when an implementer submits (task → review); architect review-queue notifier') .option('--await-review', 'Exit when an implementer submits (task → review); architect review-queue notifier')
.option('--await-message <agent>', 'Exit when an unread message arrives for agent/role; architect message notifier') .option('--new-only', 'With --await-review: fire only on NEW submissions, ignore tasks already in review on connect (re-armable without spinning)')
.option('--new-only', 'With --await-review/--await-message: ignore existing backlog on connect (re-armable without spinning)')
.action(async (options) => { .action(async (options) => {
const { serverUrl } = await resolveContext(program, cwd); const { serverUrl } = await resolveContext(program, cwd);
if (!serverUrl) { if (!serverUrl) {
@ -853,7 +651,6 @@ export function createProgram(cwd: string): Command {
once: options.once as boolean | undefined, once: options.once as boolean | undefined,
role: options.role as string | undefined, role: options.role as string | undefined,
awaitReview: options.awaitReview as boolean | undefined, awaitReview: options.awaitReview as boolean | undefined,
awaitMessage: options.awaitMessage as string | undefined,
newOnly: options.newOnly as boolean | undefined, newOnly: options.newOnly as boolean | undefined,
}); });
}); });

View File

@ -1,7 +1,6 @@
import type { Task, Handoff, Decision, Memory, Message, Ask, ActivityItem } from '../core/schema.js'; import type { Task, Handoff, Decision, Memory, Message, ActivityItem } from '../core/schema.js';
import type { IndexEntry } from '../core/index.js'; import type { IndexEntry } from '../core/index.js';
import type { InboxMessage } from '../core/services/messageService.js'; import type { InboxMessage } from '../core/services/messageService.js';
import type { TaskLogEntry } from '../core/services/taskLogService.js';
export class RemoteError extends Error { export class RemoteError extends Error {
constructor(public status: number, message: string) { constructor(public status: number, message: string) {
@ -93,14 +92,6 @@ export const remoteClient = {
return request<ActivityItem[]>(baseUrl, 'GET', `/tasks/${id}/activity`); return request<ActivityItem[]>(baseUrl, 'GET', `/tasks/${id}/activity`);
}, },
async appendTaskLog(
baseUrl: string,
id: string,
entry: { text: string; agent?: string; level?: string },
): Promise<TaskLogEntry> {
return request<TaskLogEntry>(baseUrl, 'POST', `/tasks/${id}/log`, entry);
},
async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> { async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
return request<Handoff>(baseUrl, 'POST', '/handoffs', options); return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
}, },
@ -146,36 +137,6 @@ export const remoteClient = {
return request<Message>(baseUrl, 'POST', `/messages/${id}/read`); return request<Message>(baseUrl, 'POST', `/messages/${id}/read`);
}, },
async ackMessage(baseUrl: string, id: string, by?: string): Promise<Message> {
return request<Message>(baseUrl, 'POST', `/messages/${id}/ack`, by ? { by } : undefined);
},
async getMessage(baseUrl: string, id: string): Promise<{ message: Message; body: string }> {
return request<{ message: Message; body: string }>(baseUrl, 'GET', `/messages/${id}`);
},
async createAsk(baseUrl: string, options: Partial<Ask>): Promise<Ask> {
return request<Ask>(baseUrl, 'POST', '/asks', options);
},
async listAsks(baseUrl: string, filters?: { to?: string; status?: string }): Promise<Ask[]> {
const params = new URLSearchParams((filters ?? {}) as Record<string, string>);
const qs = params.toString();
return request<Ask[]>(baseUrl, 'GET', `/asks${qs ? '?' + qs : ''}`);
},
async getAsk(baseUrl: string, id: string): Promise<{ ask: Ask; body: string }> {
return request<{ ask: Ask; body: string }>(baseUrl, 'GET', `/asks/${id}`);
},
async answerAsk(baseUrl: string, id: string, text: string, by?: string): Promise<Ask> {
return request<Ask>(baseUrl, 'POST', `/asks/${id}/answer`, { text, by });
},
async escalateAsk(baseUrl: string, id: string, note?: string): Promise<Ask> {
return request<Ask>(baseUrl, 'POST', `/asks/${id}/escalate`, note ? { note } : undefined);
},
async delegate(baseUrl: string, auto: boolean): Promise<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }> { async delegate(baseUrl: string, auto: boolean): Promise<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }> {
return request<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }>(baseUrl, 'POST', `/delegate?auto=${auto}`); return request<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }>(baseUrl, 'POST', `/delegate?auto=${auto}`);
}, },

View File

@ -7,7 +7,6 @@ const prefixes: Record<string, string> = {
decision: 'DEC', decision: 'DEC',
memory: 'MEM', memory: 'MEM',
message: 'MSG', message: 'MSG',
ask: 'ASK',
}; };
export type CounterType = keyof typeof prefixes; export type CounterType = keyof typeof prefixes;

View File

@ -14,7 +14,6 @@ export interface IndexEntry {
status?: string; status?: string;
role?: string; role?: string;
assignedTo?: string; assignedTo?: string;
claimedBy?: string;
reviewer?: string; reviewer?: string;
tags?: string; tags?: string;
// Handoff-specific routing fields // Handoff-specific routing fields
@ -47,7 +46,6 @@ export class Index {
status TEXT, status TEXT,
role TEXT, role TEXT,
assignedTo TEXT, assignedTo TEXT,
claimedBy TEXT,
reviewer TEXT, reviewer TEXT,
tags TEXT, tags TEXT,
fromRole TEXT, fromRole TEXT,
@ -64,26 +62,18 @@ export class Index {
const existingCols = new Set( const existingCols = new Set(
(this.db.pragma('table_info(entities)') as Array<{ name: string }>).map((r) => r.name), (this.db.pragma('table_info(entities)') as Array<{ name: string }>).map((r) => r.name),
); );
for (const col of ['claimedBy', 'reviewer', 'fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) { for (const col of ['reviewer', 'fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) {
if (!existingCols.has(col)) { if (!existingCols.has(col)) {
this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`); this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`);
} }
} }
} }
/** upsert(entry: IndexEntry): void {
* Upsert an entity into the index. Pass `{ fts: false }` to keep it out of the
* full-text search table (e.g. Asks transient decision-routing questions
* that shouldn't pollute `memory search`); it still lives in the `entities`
* table so it can be listed/filtered by type.
*/
upsert(entry: IndexEntry, opts: { fts?: boolean } = {}): void {
const { fts = true } = opts;
const params = { const params = {
status: null, status: null,
role: null, role: null,
assignedTo: null, assignedTo: null,
claimedBy: null,
reviewer: null, reviewer: null,
tags: null, tags: null,
fromRole: null, fromRole: null,
@ -96,25 +86,21 @@ export class Index {
}; };
const insert = this.db.prepare(` const insert = this.db.prepare(`
INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, claimedBy, reviewer, tags, fromRole, toRole, fromAgent, toAgent, taskId, relatedTasks) INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, reviewer, tags, fromRole, toRole, fromAgent, toAgent, taskId, relatedTasks)
VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @claimedBy, @reviewer, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks) VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @reviewer, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
type=@type, title=@title, content=@content, filePath=@filePath, type=@type, title=@title, content=@content, filePath=@filePath,
createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role, createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role,
assignedTo=@assignedTo, claimedBy=@claimedBy, reviewer=@reviewer, tags=@tags, assignedTo=@assignedTo, reviewer=@reviewer, tags=@tags,
fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent, fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent,
taskId=@taskId, relatedTasks=@relatedTasks taskId=@taskId, relatedTasks=@relatedTasks
`); `);
insert.run(params); insert.run(params);
if (fts) { const search = this.db.prepare(`
// The FTS5 `search` table has no UNIQUE key on `id` (it's a plain indexed INSERT OR REPLACE INTO search (id, title, content) VALUES (@id, @title, @content)
// column), so `INSERT OR REPLACE` would NOT replace — it would append a new `);
// row on every update, and the search JOIN would then return the same id search.run(params);
// multiple times. Delete-then-insert keeps exactly one FTS row per id.
this.db.prepare('DELETE FROM search WHERE id = @id').run({ id: params.id });
this.db.prepare('INSERT INTO search (id, title, content) VALUES (@id, @title, @content)').run(params);
}
} }
/** Permanently drop an entity from both the entities table and the FTS index. */ /** Permanently drop an entity from both the entities table and the FTS index. */

View File

@ -1,7 +1,7 @@
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { dirname, join, parse } from 'path'; import { dirname, join, parse } from 'path';
export type EntityType = 'tasks' | 'handoffs' | 'decisions' | 'memory' | 'messages' | 'asks' | 'status'; export type EntityType = 'tasks' | 'handoffs' | 'decisions' | 'memory' | 'messages' | 'status';
export function getAgentHubDir(cwd: string = process.cwd()): string { export function getAgentHubDir(cwd: string = process.cwd()): string {
return join(cwd, '.agenthub'); return join(cwd, '.agenthub');

View File

@ -15,7 +15,6 @@ export const TaskSchema = z.object({
priority: Priority.default('medium'), priority: Priority.default('medium'),
role: Role.optional(), role: Role.optional(),
assignedTo: z.string().optional(), assignedTo: z.string().optional(),
claimedBy: z.string().optional(),
reviewer: z.string().optional(), reviewer: z.string().optional(),
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
updatedAt: z.string().datetime(), updatedAt: z.string().datetime(),
@ -84,40 +83,7 @@ export const MessageSchema = z.object({
to: z.string().min(1), to: z.string().min(1),
text: z.string().min(1), text: z.string().min(1),
taskId: z.string().optional(), taskId: z.string().optional(),
/** status: z.enum(['unread', 'read']).default('unread'),
* Read-receipt lifecycle:
* unread created, never fetched by the recipient
* delivered surfaced to the recipient's inbox (fetched), not yet opened
* read recipient marked it read
* acked recipient explicitly acknowledged/actioned it
* Additive + backward-compatible: old records ('unread'|'read') stay valid.
*/
status: z.enum(['unread', 'delivered', 'read', 'acked']).default('unread'),
/** For threaded replies: the id of the parent message this answers. */
replyTo: z.string().optional(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export const AskStatus = z.enum(['pending', 'answered', 'escalated']);
/**
* A blocking question routed for a decision (TSK-0118). Unlike a message (a
* fire-and-forget ping), an Ask has a lifecycle: an implementer asks, the
* architect answers (closing it) or escalates it to the CEO. It is the single
* blocking channel an implementer awaits exactly ONE Ask, never two.
*/
export const AskSchema = z.object({
id: z.string().regex(/^ASK-\d{4}$/),
from: z.string().min(1),
to: z.string().min(1),
question: z.string().min(1),
taskId: z.string().optional(),
status: AskStatus.default('pending'),
answer: z.string().optional(),
answeredBy: z.string().optional(),
escalatedTo: z.string().optional(),
decisionId: z.string().optional(),
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
updatedAt: z.string().datetime(), updatedAt: z.string().datetime(),
}); });
@ -204,6 +170,5 @@ export type Handoff = z.infer<typeof HandoffSchema>;
export type Decision = z.infer<typeof DecisionSchema>; export type Decision = z.infer<typeof DecisionSchema>;
export type Memory = z.infer<typeof MemorySchema>; export type Memory = z.infer<typeof MemorySchema>;
export type Message = z.infer<typeof MessageSchema>; export type Message = z.infer<typeof MessageSchema>;
export type Ask = z.infer<typeof AskSchema>;
export type Status = z.infer<typeof StatusSchema>; export type Status = z.infer<typeof StatusSchema>;
export type Config = z.infer<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;

View File

@ -100,7 +100,7 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] {
let summary: string; let summary: string;
switch (task.status) { switch (task.status) {
case 'in_progress': case 'in_progress':
summary = task.claimedBy ? `Claimed by ${task.claimedBy}` : task.assignedTo ? `Claimed by ${task.assignedTo}` : 'Claimed'; summary = task.assignedTo ? `Claimed by ${task.assignedTo}` : 'Claimed';
break; break;
case 'review': case 'review':
summary = 'Submitted for review'; summary = 'Submitted for review';
@ -117,7 +117,6 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] {
const meta: Record<string, unknown> = { status: task.status }; const meta: Record<string, unknown> = { status: task.status };
if (task.assignedTo) meta.assignedTo = task.assignedTo; if (task.assignedTo) meta.assignedTo = task.assignedTo;
if (task.claimedBy) meta.claimedBy = task.claimedBy;
if (task.doneBy) meta.by = task.doneBy; if (task.doneBy) meta.by = task.doneBy;
if (task.doneTokens != null) meta.tokens = task.doneTokens; if (task.doneTokens != null) meta.tokens = task.doneTokens;
if (task.doneDuration != null) meta.duration = task.doneDuration; if (task.doneDuration != null) meta.duration = task.doneDuration;
@ -125,7 +124,7 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] {
items.push({ items.push({
at: task.updatedAt, at: task.updatedAt,
kind: 'status', kind: 'status',
actor: task.doneBy ?? task.claimedBy ?? task.assignedTo ?? task.role ?? 'unknown', actor: task.doneBy ?? task.assignedTo ?? task.role ?? 'unknown',
summary, summary,
meta, meta,
}); });

View File

@ -1,142 +0,0 @@
import { join } from 'path';
import { getEntityDir } from '../paths.js';
import { getNextId } from '../counter.js';
import { readEntity, writeEntity } from '../files.js';
import { AskSchema, type Ask } from '../schema.js';
import { Index } from '../index.js';
import { loadConfig } from '../config.js';
/**
* Ask primitive autonomous decision-routing (TSK-0118).
*
* ## Authority policy
* An Ask is the single blocking channel for a decision an agent can't make
* alone. It flows strictly:
*
* implementer ARCHITECT (answer | escalate CEO)
*
* - Implementer questions ALWAYS route to the architect (never the CEO). The
* implementer awaits exactly ONE Ask never a second, parallel blocking
* channel.
* - The architect answers most Asks himself, within the approve/push gate.
* - The architect MUST escalate (not answer) for: release / publish / push,
* OSS decisions, and architecture pivots. Escalation flips the Ask to
* `escalated` (escalatedTo = 'ceo') and closes the loop through the SAME Ask
* the implementer keeps waiting on that one Ask, it never opens a CEO channel.
*/
function indexEntryFor(record: Ask, filePath: string) {
return {
id: record.id,
type: 'ask',
title: `${record.from}${record.to}`,
content: record.question,
filePath,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
status: record.status,
fromAgent: record.from,
toAgent: record.to,
taskId: record.taskId,
};
}
/**
* Who an Ask routes to by default: the architect's preferred agent. NEVER the
* CEO a raw question is the architect's to field; only an explicit escalation
* reaches the CEO.
*/
function defaultRoutedTo(cwd: string): string {
try {
return loadConfig(cwd).roles?.architect?.preferredAgent || 'architect';
} catch {
return 'architect';
}
}
/** Ask a routed question. Defaults to the architect; a `to` of 'ceo' is refused
* (rerouted to the architect) the CEO is reachable only via escalateAsk. */
export function createAsk(cwd: string, options: Partial<Ask> = {}): Ask {
if (!options.from) throw new Error('Ask requires a "from" agent');
if (!options.question) throw new Error('Ask requires a question');
const now = new Date().toISOString();
const to = options.to && options.to.toLowerCase() !== 'ceo' ? options.to : defaultRoutedTo(cwd);
const record: Ask = AskSchema.parse({
id: getNextId(cwd, 'ask'),
from: options.from,
to,
question: options.question,
taskId: options.taskId,
status: 'pending',
createdAt: now,
updatedAt: now,
});
const filePath = join(getEntityDir(cwd, 'asks'), `${record.id}.md`);
writeEntity(filePath, record, `# ${record.from}${record.to}\n\n${record.question}`);
const index = new Index(cwd);
index.upsert(indexEntryFor(record, filePath), { fts: false }); // keep Asks out of FTS5
index.close();
return record;
}
export function getAsk(cwd: string, id: string): { ask: Ask; body: string; filePath: string } {
if (!id) throw new Error('Ask ID is required');
const filePath = join(getEntityDir(cwd, 'asks'), `${id}.md`);
const { frontmatter, body } = readEntity(filePath);
return { ask: AskSchema.parse(frontmatter), body, filePath };
}
/** Asks, newest first, optionally filtered by recipient and/or status. */
export function listAsks(cwd: string, opts: { to?: string; status?: string } = {}): Ask[] {
const index = new Index(cwd);
const entries = index.list('ask', opts.status ? { status: opts.status } : undefined);
index.close();
const ids = entries
.filter((e) => !opts.to || (e.toAgent != null && String(e.toAgent).toLowerCase() === opts.to.toLowerCase()))
.map((e) => e.id);
const out: Ask[] = [];
for (const id of ids) {
try {
out.push(getAsk(cwd, id).ask);
} catch {
/* torn file — skip */
}
}
return out;
}
/** Answer an Ask (architect resolves it in the approve/push gate). */
export function answerAsk(cwd: string, id: string, text: string, by?: string): Ask {
if (!text || !text.trim()) throw new Error('Ask answer requires text');
const { ask, body, filePath } = getAsk(cwd, id);
const updated: Ask = { ...ask, status: 'answered', answer: text, answeredBy: by, updatedAt: new Date().toISOString() };
writeEntity(filePath, updated, body);
const index = new Index(cwd);
index.upsert(indexEntryFor(updated, filePath), { fts: false });
index.close();
return updated;
}
/**
* Escalate an Ask to the CEO (required for release/publish/push, OSS, and
* architecture pivots). This does NOT open a second blocking channel it flips
* the SAME Ask to `escalated`; the waiting implementer resolves on this one Ask.
*/
export function escalateAsk(cwd: string, id: string, note?: string): Ask {
const { ask, body, filePath } = getAsk(cwd, id);
const updated: Ask = { ...ask, status: 'escalated', escalatedTo: 'ceo', updatedAt: new Date().toISOString() };
const newBody = note ? `${body}\n\n_escalated to ceo: ${note}_` : body;
writeEntity(filePath, updated, newBody);
const index = new Index(cwd);
index.upsert(indexEntryFor(updated, filePath), { fts: false });
index.close();
return updated;
}

View File

@ -34,7 +34,6 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
to: options.to, to: options.to,
text: options.text, text: options.text,
taskId: options.taskId, taskId: options.taskId,
replyTo: options.replyTo,
status: 'unread', status: 'unread',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -50,17 +49,6 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
return record; return record;
} }
/** Agent aliases that should see the same inbox. Keep deliberately small. */
export function messageRecipientAliases(agent: string): Set<string> {
const key = agent.toLowerCase();
const aliases = new Set([agent, key]);
if (key === 'architect' || key === 'claude') {
aliases.add('architect');
aliases.add('claude');
}
return aliases;
}
export interface InboxMessage { export interface InboxMessage {
id: string; id: string;
from: string; from: string;
@ -71,46 +59,23 @@ export interface InboxMessage {
createdAt: string; createdAt: string;
} }
/** /** Messages addressed to `agent`, newest first. */
* Messages addressed to `agent`, newest first.
*
* Read-receipt side effect: any still-`unread` message that passes the filter is
* transitioned to `delivered` (a message the recipient has now been shown) AFTER
* filtering and BEFORE returning, so the returned rows reflect the new status.
* This is agent-scoped only the architect-wide `listMessages` never mutates.
* (The `agenthub work` drainInbox path filters `unreadOnly` BEFORE this mutation
* and then marks read, so the delivered intermediate is invisible there no
* regress and no spin.)
*/
export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boolean } = {}): InboxMessage[] { export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boolean } = {}): InboxMessage[] {
const index = new Index(cwd); const index = new Index(cwd);
const all = index.list('message'); const all = index.list('message');
index.close(); index.close();
const recipients = messageRecipientAliases(agent); return all
const filtered = all .filter((m) => m.toAgent === agent)
.filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase())) .filter((m) => !opts.unreadOnly || m.status === 'unread')
.filter((m) => !opts.unreadOnly || m.status === 'unread'); .map((m) => ({
return filtered.map((m) => {
let status = m.status ?? 'unread';
if (status === 'unread') {
try {
markMessageDelivered(cwd, m.id);
status = 'delivered';
} catch {
/* best-effort: leave as unread if the file can't be updated */
}
}
return {
id: m.id, id: m.id,
from: m.fromAgent ?? '', from: m.fromAgent ?? '',
to: m.toAgent ?? '', to: m.toAgent ?? '',
text: m.content, text: m.content,
taskId: m.taskId, taskId: m.taskId,
status, status: m.status ?? 'unread',
createdAt: m.createdAt, createdAt: m.createdAt,
}; }));
});
} }
/** All messages (architect-visible view), newest first. */ /** All messages (architect-visible view), newest first. */
@ -136,26 +101,6 @@ export function getMessage(cwd: string, id: string): { message: Message; body: s
return { message: MessageSchema.parse(frontmatter), body, filePath }; return { message: MessageSchema.parse(frontmatter), body, filePath };
} }
/**
* Transition a message from `unread` to `delivered` (the recipient has been
* shown it). Idempotent: a message that is already delivered/read/acked is left
* untouched this never downgrades a stronger receipt.
*/
export function markMessageDelivered(cwd: string, id: string): Message {
const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`);
const { frontmatter, body } = readEntity(filePath);
const message = MessageSchema.parse(frontmatter);
if (message.status !== 'unread') return message;
const updated: Message = { ...message, status: 'delivered', updatedAt: new Date().toISOString() };
writeEntity(filePath, updated, body);
const index = new Index(cwd);
index.upsert(indexEntryFor(updated, filePath));
index.close();
return updated;
}
/** Mark a message as read. */ /** Mark a message as read. */
export function markMessageRead(cwd: string, id: string): Message { export function markMessageRead(cwd: string, id: string): Message {
const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`); const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`);
@ -170,22 +115,3 @@ export function markMessageRead(cwd: string, id: string): Message {
return updated; return updated;
} }
/**
* Acknowledge a message the recipient has explicitly actioned it (the strongest
* receipt). Optionally records who acked in the body trailer.
*/
export function ackMessage(cwd: string, id: string, by?: string): Message {
const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`);
const { frontmatter, body } = readEntity(filePath);
const message = MessageSchema.parse(frontmatter);
const updated: Message = { ...message, status: 'acked', updatedAt: new Date().toISOString() };
const newBody = by ? `${body}\n\n_acked by ${by}_` : body;
writeEntity(filePath, updated, newBody);
const index = new Index(cwd);
index.upsert(indexEntryFor(updated, filePath));
index.close();
return updated;
}

View File

@ -17,7 +17,6 @@ export function createTask(cwd: string, options: Partial<Task> = {}): Task {
priority: options.priority ?? 'medium', priority: options.priority ?? 'medium',
role: options.role, role: options.role,
assignedTo: options.assignedTo, assignedTo: options.assignedTo,
claimedBy: options.claimedBy,
reviewer: options.reviewer, reviewer: options.reviewer,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -59,30 +58,8 @@ export function updateTask(cwd: string, id: string, patch: Partial<Task>): Task
return updated; return updated;
} }
/**
* Claim a task for an agent (open in_progress). Race-guarded: only an OPEN
* task can be claimed. A re-claim by the SAME agent (retried request) is an
* idempotent no-op; a task already claimed by someone else or past `open`
* (review/done/cancelled) is refused, so two agents can't claim the same task
* and a claim can't clobber another agent's in-progress work.
*
* `getTask`+`updateTask` are synchronous, so within the single-threaded server
* event loop this read-check-write is effectively atomic: of two concurrent
* claims on the same open task, the first completes the write and the second
* then sees `in_progress` and is refused.
*/
export function claimTask(cwd: string, id: string, agentName: string): Task { export function claimTask(cwd: string, id: string, agentName: string): Task {
const { task } = getTask(cwd, id); return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName });
// Idempotent: the same agent re-claiming its own in-progress task is a no-op.
if (task.status === 'in_progress' && task.claimedBy === agentName) {
return task;
}
if (task.status !== 'open') {
throw new Error(
`Task ${id} cannot be claimed — status is "${task.status}"${task.claimedBy ? ` (claimed by ${task.claimedBy})` : ''}.`,
);
}
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName, claimedBy: agentName });
} }
export function doneTask( export function doneTask(
@ -164,7 +141,6 @@ function toIndexEntry(task: Task, filePath: string) {
status: task.status, status: task.status,
role: task.role, role: task.role,
assignedTo: task.assignedTo, assignedTo: task.assignedTo,
claimedBy: task.claimedBy,
reviewer: task.reviewer, reviewer: task.reviewer,
tags: JSON.stringify(task.tags), tags: JSON.stringify(task.tags),
}; };

View File

@ -22,18 +22,17 @@ export function resolveAdvertiseUrl(host: string, port: number): string {
return `http://${advertiseHost}:${port}`; return `http://${advertiseHost}:${port}`;
} }
export function startDiscoveryBroadcaster(getServerUrl: string | (() => string), options?: { port?: number; intervalMs?: number }) { export function startDiscoveryBroadcaster(serverUrl: string, options?: { port?: number; intervalMs?: number }) {
const port = options?.port ?? DISCOVERY_PORT; const port = options?.port ?? DISCOVERY_PORT;
const intervalMs = options?.intervalMs ?? 2000; const intervalMs = options?.intervalMs ?? 2000;
const socket = dgram.createSocket('udp4'); const socket = dgram.createSocket('udp4');
const resolveUrl = typeof getServerUrl === 'function' ? getServerUrl : () => getServerUrl; const message = Buffer.from(`${DISCOVERY_PREFIX}${serverUrl}`);
socket.on('error', () => { socket.on('error', () => {
// Discovery is best-effort; ignore network errors. // Discovery is best-effort; ignore network errors.
}); });
const send = () => { const send = () => {
const message = Buffer.from(`${DISCOVERY_PREFIX}${resolveUrl()}`);
try { try {
socket.send(message, 0, message.length, port, DISCOVERY_MULTICAST); socket.send(message, 0, message.length, port, DISCOVERY_MULTICAST);
} catch { } catch {

View File

@ -17,14 +17,10 @@ import {
doneTask, doneTask,
} from '../core/services/taskService.js'; } from '../core/services/taskService.js';
import { createHandoff, getHandoff } from '../core/services/handoffService.js'; import { createHandoff, getHandoff } from '../core/services/handoffService.js';
import { appendTaskLog } from '../core/services/taskLogService.js';
import { createAsk, listAsks, answerAsk, escalateAsk } from '../core/services/askService.js';
import type { Ask } from '../core/schema.js';
import { createMessage, listInbox, markMessageRead } from '../core/services/messageService.js'; import { createMessage, listInbox, markMessageRead } from '../core/services/messageService.js';
import { addMemory, searchMemory } from '../core/services/memoryService.js'; import { addMemory, searchMemory } from '../core/services/memoryService.js';
import { createDecision } from '../core/services/decisionService.js'; import { createDecision } from '../core/services/decisionService.js';
import { getStatus } from '../core/services/statusService.js'; import { getStatus } from '../core/services/statusService.js';
import { discoverServer as discoverHubServer } from '../discovery.js';
/** /**
* AgentHub MCP server (TSK-0030). * AgentHub MCP server (TSK-0030).
@ -55,88 +51,44 @@ function asText(value: unknown) {
return { content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] }; return { content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] };
} }
function remainingMs(deadline: number | undefined): number {
return deadline === undefined ? Number.POSITIVE_INFINITY : Math.max(0, deadline - Date.now());
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function resolveReconnectUrl(currentUrl: string): Promise<string> {
if (process.env.AGENTHUB_SERVER) return process.env.AGENTHUB_SERVER;
const discovered = await discoverHubServer(2000);
return discovered || currentUrl;
}
/** Block on the SSE stream until findClaim() returns a task, or timeout. */ /** Block on the SSE stream until findClaim() returns a task, or timeout. */
function waitForTask<T>( function waitForTask<T>(serverUrl: string, findClaim: () => Promise<T | null>, timeoutSec: number): Promise<T | null> {
serverUrl: string,
findClaim: (serverUrl: string) => Promise<T | null>,
timeoutSec: number,
): Promise<T | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
const controller = new AbortController();
let settled = false; let settled = false;
let controller: AbortController | undefined;
let currentUrl = serverUrl;
const deadline = Date.now() + Math.max(1, timeoutSec) * 1000;
const backoffs = [2000, 5000, 10000];
let reconnectAttempt = 0;
const finish = (v: T | null) => { const finish = (v: T | null) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
try { controller?.abort(); } catch { /* already */ } try { controller.abort(); } catch { /* already */ }
resolve(v); resolve(v);
}; };
const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000); const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000);
const waitLoop = async () => { fetch(new URL('/events', serverUrl).toString(), { signal: controller.signal, headers: { Accept: 'text/event-stream' } })
while (!settled && remainingMs(deadline) > 0) { .then(async (res) => {
controller = new AbortController(); if (!res.body) { clearTimeout(timer); finish(null); return; }
try { // Close the gap: a task may have arrived between the initial check and now.
const res = await fetch(new URL('/events', currentUrl).toString(), { const early = await findClaim();
signal: controller.signal, if (early) { clearTimeout(timer); finish(early); return; }
headers: { Accept: 'text/event-stream' }, const reader = res.body.getReader();
}); const decoder = new TextDecoder();
if (!res.body) throw new Error('SSE response has no body'); let buffer = '';
// Close the gap: a task may have arrived between the initial check and now. while (!settled) {
const early = await findClaim(currentUrl); let done: boolean; let value: Uint8Array | undefined;
if (early) { clearTimeout(timer); finish(early); return; } try { ({ done, value } = await reader.read()); } catch { break; }
const reader = res.body.getReader(); if (done) break;
const decoder = new TextDecoder(); if (value) buffer += decoder.decode(value, { stream: true });
let buffer = ''; const { events, remaining } = parseSSEBuffer(buffer);
reconnectAttempt = 0; buffer = remaining;
while (!settled) { // Wake on a new task OR a new message addressed to the agent.
let done: boolean; let value: Uint8Array | undefined; if (events.some((e) => e.type === 'task' || e.type === 'message')) {
try { ({ done, value } = await reader.read()); } catch { break; } const claimed = await findClaim();
if (done) break; if (claimed) { clearTimeout(timer); finish(claimed); return; }
if (value) buffer += decoder.decode(value, { stream: true });
const { events, remaining } = parseSSEBuffer(buffer);
buffer = remaining;
// Wake on a new task, a message, or an ask (decision routed to the
// architect / answered back to the asker).
if (events.some((e) => e.type === 'task' || e.type === 'message' || e.type === 'ask')) {
const claimed = await findClaim(currentUrl);
if (claimed) { clearTimeout(timer); finish(claimed); return; }
}
} }
} catch (err: unknown) {
if (settled || (err instanceof Error && err.name === 'AbortError')) return;
} }
if (settled || remainingMs(deadline) <= 0) break; clearTimeout(timer); finish(null);
currentUrl = await resolveReconnectUrl(currentUrl); })
const backoff = backoffs[Math.min(reconnectAttempt, backoffs.length - 1)] ?? 10_000; .catch(() => { clearTimeout(timer); finish(null); });
reconnectAttempt += 1;
await sleep(Math.min(backoff, remainingMs(deadline)));
}
clearTimeout(timer);
finish(null);
};
waitLoop().catch(() => {
clearTimeout(timer);
finish(null);
});
}); });
} }
@ -154,82 +106,63 @@ export async function startMcpServer(cwd: string): Promise<void> {
server.tool('agenthub_work', server.tool('agenthub_work',
'Block until there is work for you, then return it. IMPLEMENTER: waits for a task addressed to you (newly delegated OR reopened), claims it, returns it with its handoff (loop: work -> implement -> agenthub_task_review -> work). ARCHITECT/REVIEWER: waits for any task submitted to review and returns the pending set to approve (agenthub_task_done) or reject (agenthub_task_reopen) — so review submissions reach you in realtime.', 'Block until there is work for you, then return it. IMPLEMENTER: waits for a task addressed to you (newly delegated OR reopened), claims it, returns it with its handoff (loop: work -> implement -> agenthub_task_review -> work). ARCHITECT/REVIEWER: waits for any task submitted to review and returns the pending set to approve (agenthub_task_done) or reject (agenthub_task_reopen) — so review submissions reach you in realtime.',
{ agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional(), unattended: z.boolean().optional() }, { agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional() },
async ({ agent, role, timeoutSec, unattended }) => { async ({ agent, role, timeoutSec }) => {
const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' }; const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' };
const reviewer = isReviewerRole(ctx.role); const reviewer = isReviewerRole(ctx.role);
const useServerUrl = (nextServerUrl?: string) => {
if (nextServerUrl) ctx.serverUrl = nextServerUrl;
return ctx.serverUrl!;
};
// Fetch + mark-read the agent's unread messages, so the work loop surfaces // Fetch + mark-read the agent's unread messages, so the work loop surfaces
// them once and doesn't spin on the same message. // them once and doesn't spin on the same message.
const drainInbox = async (nextServerUrl?: string) => { const drainInbox = async () => {
const activeUrl = nextServerUrl ? useServerUrl(nextServerUrl) : ctx.serverUrl; const msgs = remote ? await remoteClient.getInbox(serverUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true });
const msgs = remote ? await remoteClient.getInbox(activeUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true });
for (const m of msgs) { for (const m of msgs) {
try { if (remote) await remoteClient.markMessageRead(activeUrl!, m.id); else markMessageRead(root, m.id); } catch { /* best-effort */ } try { if (remote) await remoteClient.markMessageRead(serverUrl!, m.id); else markMessageRead(root, m.id); } catch { /* best-effort */ }
} }
return msgs; return msgs;
}; };
const findWork = async (nextServerUrl?: string) => { const findWork = async () => {
if (nextServerUrl) useServerUrl(nextServerUrl);
const found = await findAddressedOpenTask(ctx); const found = await findAddressedOpenTask(ctx);
if (found) { if (found) {
if (remote) await remoteClient.claimTask(ctx.serverUrl!, found.task.id, agent); if (remote) await remoteClient.claimTask(serverUrl!, found.task.id, agent);
else claimTask(root, found.task.id, agent); else claimTask(root, found.task.id, agent);
const detail = remote ? await remoteClient.getTask(ctx.serverUrl!, found.task.id) : getTask(root, found.task.id); const detail = remote ? await remoteClient.getTask(serverUrl!, found.task.id) : getTask(root, found.task.id);
const hofEntry = found.handoffs.find((h) => h.taskId === found.task.id); const hofEntry = found.handoffs.find((h) => h.taskId === found.task.id);
let handoff: unknown = null; let handoff: unknown = null;
if (hofEntry) { if (hofEntry) {
try { handoff = remote ? await remoteClient.getHandoff(ctx.serverUrl!, hofEntry.id) : getHandoff(root, hofEntry.id); } catch { /* optional */ } try { handoff = remote ? await remoteClient.getHandoff(serverUrl!, hofEntry.id) : getHandoff(root, hofEntry.id); } catch { /* optional */ }
} }
return { claimed: found.task, body: (detail as { body?: string }).body, handoff, messages: await drainInbox(ctx.serverUrl) }; return { claimed: found.task, body: (detail as { body?: string }).body, handoff, messages: await drainInbox() };
} }
const messages = await drainInbox(ctx.serverUrl); const messages = await drainInbox();
if (messages.length) return { claimed: null, messages, note: 'No task addressed to you, but you have messages — reply with agenthub_message.' }; if (messages.length) return { claimed: null, messages, note: 'No task addressed to you, but you have messages — reply with agenthub_message.' };
return null; return null;
}; };
// Architect/reviewer variant: wake on tasks submitted to review (not on // Architect/reviewer variant: wake on tasks submitted to review (not on
// tasks addressed to you). Returns the pending review set — never claims. // tasks addressed to you). Returns the pending review set — never claims.
const listPendingAsks = async (): Promise<Ask[]> => const findReview = async () => {
remote ? await remoteClient.listAsks(ctx.serverUrl!, { status: 'pending' }) : listAsks(root, { status: 'pending' });
const findReview = async (nextServerUrl?: string) => {
if (nextServerUrl) useServerUrl(nextServerUrl);
const reviews = await listReviewTasks(ctx); const reviews = await listReviewTasks(ctx);
const messages = await drainInbox(ctx.serverUrl); const messages = await drainInbox();
const asks = await listPendingAsks(); if (reviews.length) {
if (reviews.length || asks.length) {
return { return {
reviews: reviews.map((r) => ({ id: r.id, title: r.title, assignedTo: r.assignedTo })), reviews: reviews.map((r) => ({ id: r.id, title: r.title, assignedTo: r.assignedTo })),
asks: asks.map((a) => ({ id: a.id, from: a.from, question: a.question, taskId: a.taskId })),
messages, messages,
note: 'Tasks awaiting your review (approve: agenthub_task_done; reject: agenthub_task_reopen). Pending asks: answer with agenthub_ask_answer, or escalate to the CEO with agenthub_ask_escalate (release/publish/push, OSS, architecture pivots).', note: 'Tasks awaiting your review. Approve: agenthub_task_done. Reject: agenthub_task_reopen (send feedback first via agenthub_message or a handoff). Read the work with agenthub_task_show.',
}; };
} }
if (messages.length) return { reviews: [], asks: [], messages, note: 'Nothing in review, but you have messages — reply with agenthub_message.' }; if (messages.length) return { reviews: [], messages, note: 'Nothing in review, but you have messages — reply with agenthub_message.' };
return null; return null;
}; };
const finder: (nextServerUrl?: string) => Promise<Record<string, unknown> | null> = reviewer ? findReview : findWork; const finder: () => Promise<Record<string, unknown> | null> = reviewer ? findReview : findWork;
// Self-perpetuating loop: every response reminds the agent to relaunch // Self-perpetuating loop: every response reminds the agent to relaunch
// agenthub_work, so a finished task/message never leaves it dormant. // agenthub_work, so a finished task/message never leaves it dormant.
let LOOP = reviewer const LOOP = reviewer
? 'STAY REACHABLE: the instant you finish reviewing, call agenthub_work again — ' + ? 'STAY REACHABLE: the instant you finish reviewing, call agenthub_work again — ' +
'run it in the background so the next review submission or ask reaches you in realtime. ' + 'run it in the background so the next review submission reaches you in realtime. ' +
'Never end your turn without relaunching agenthub_work.' 'Never end your turn without relaunching agenthub_work.'
: 'STAY REACHABLE: the instant you finish handling this, call agenthub_work again — ' + : 'STAY REACHABLE: the instant you finish handling this, call agenthub_work again — ' +
'run it in the background so the next task or message reaches you in realtime. ' + 'run it in the background so the next task or message reaches you in realtime. ' +
'Report meaningful progress with agenthub_task_log while you work. ' +
'ESPECIALLY after you submit with agenthub_task_review: do NOT end your turn — ' +
'immediately relaunch agenthub_work and keep it running, so the architect\'s approval, ' +
'reopen (with feedback) or follow-up message wakes you here instead of leaving you dormant. ' +
'Never end your turn without relaunching agenthub_work.'; 'Never end your turn without relaunching agenthub_work.';
if (unattended) {
LOOP += ' UNATTENDED MODE: never pause for human input — when you need a decision, ' +
'call agenthub_ask (it routes to the architect) and await the answer instead of stalling.';
}
const immediate = await finder(); const immediate = await finder();
if (immediate) return asText({ ...immediate, loop: LOOP }); if (immediate) return asText({ ...immediate, loop: LOOP });
const emptyMsg = reviewer const emptyMsg = reviewer
@ -278,11 +211,6 @@ export async function startMcpServer(cwd: string): Promise<void> {
{ id: z.string() }, { id: z.string() },
async ({ id }) => asText(remote ? await remoteClient.doneTask(serverUrl!, id, {}) : doneTask(root, id))); async ({ id }) => asText(remote ? await remoteClient.doneTask(serverUrl!, id, {}) : doneTask(root, id)));
server.tool('agenthub_task_log',
'Report meaningful progress on the task you are working on — one short line — so the architect can watch it live on the task console. Call it as you work (e.g. "wrote failing test", "green: 12 tests", "blocked on X").',
{ id: z.string(), text: z.string(), agent: z.string().optional(), level: z.string().optional() },
async ({ id, text, agent, level }) => asText(remote ? await remoteClient.appendTaskLog(serverUrl!, id, { text, agent, level }) : appendTaskLog(root, id, { text, agent, level })));
server.tool('agenthub_memory_add', 'Record a result / finding / blocker as a memory entry.', server.tool('agenthub_memory_add', 'Record a result / finding / blocker as a memory entry.',
{ title: z.string(), category: z.enum(['architecture', 'product', 'technical', 'implementation', 'lesson']).optional(), content: z.string() }, { title: z.string(), category: z.enum(['architecture', 'product', 'technical', 'implementation', 'lesson']).optional(), content: z.string() },
async (o) => asText(remote ? await remoteClient.addMemory(serverUrl!, o) : addMemory(root, o))); async (o) => asText(remote ? await remoteClient.addMemory(serverUrl!, o) : addMemory(root, o)));
@ -317,36 +245,6 @@ export async function startMcpServer(cwd: string): Promise<void> {
{ agent: z.string(), unreadOnly: z.boolean().optional() }, { agent: z.string(), unreadOnly: z.boolean().optional() },
async ({ agent, unreadOnly }) => asText(remote ? await remoteClient.getInbox(serverUrl!, agent, unreadOnly) : listInbox(root, agent, { unreadOnly }))); async ({ agent, unreadOnly }) => asText(remote ? await remoteClient.getInbox(serverUrl!, agent, unreadOnly) : listInbox(root, agent, { unreadOnly })));
server.tool('agenthub_ask',
'Ask the architect a blocking question when you cannot decide alone (autonomous decision-routing). Routes to the architect — NEVER the CEO. With wait:true it blocks until the architect answers or escalates, then returns the resolution. Await exactly ONE ask at a time.',
{ from: z.string(), question: z.string(), taskId: z.string().optional(), wait: z.boolean().optional(), timeoutSec: z.number().optional() },
async ({ from, question, taskId, wait, timeoutSec }) => {
const ask = remote ? await remoteClient.createAsk(serverUrl!, { from, question, taskId }) : createAsk(root, { from, question, taskId });
if (!wait) return asText(ask);
if (!remote) return asText({ ...ask, note: 'Created; --wait needs a running hub server.' });
const settled = await waitForTask<Ask>(serverUrl!, async (url) => {
const { ask: cur } = await remoteClient.getAsk(url, ask.id);
return cur.status !== 'pending' ? cur : null;
}, timeoutSec ?? 300);
if (settled) return asText(settled);
return asText({ ...ask, note: `No answer within ${timeoutSec ?? 300}s — re-check with agenthub_ask_list.` });
});
server.tool('agenthub_ask_list',
'List asks (routed decisions). Architect: your pending decision queue. Filter by status (e.g. pending) and/or recipient.',
{ to: z.string().optional(), status: z.string().optional() },
async ({ to, status }) => asText(remote ? await remoteClient.listAsks(serverUrl!, { to, status }) : listAsks(root, { to, status })));
server.tool('agenthub_ask_answer',
'Answer an ask (architect resolves a routed decision within the approve/push gate).',
{ id: z.string(), text: z.string(), by: z.string().optional() },
async ({ id, text, by }) => asText(remote ? await remoteClient.answerAsk(serverUrl!, id, text, by) : answerAsk(root, id, text, by)));
server.tool('agenthub_ask_escalate',
'Escalate an ask to the CEO (architect: REQUIRED for release/publish/push, OSS decisions and architecture pivots). Closes the loop through the same ask — no second blocking channel.',
{ id: z.string(), note: z.string().optional() },
async ({ id, note }) => asText(remote ? await remoteClient.escalateAsk(serverUrl!, id, note) : escalateAsk(root, id, note)));
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();
await server.connect(transport); await server.connect(transport);
// stdio servers must not write to stdout (it's the protocol channel); log to stderr. // stdio servers must not write to stdout (it's the protocol channel); log to stderr.

View File

@ -1,4 +1,5 @@
import { loadConfig } from '../core/config.js'; import { loadConfig } from '../core/config.js';
import { listMessages } from '../core/services/messageService.js';
import { listTasks } from '../core/services/taskService.js'; import { listTasks } from '../core/services/taskService.js';
import { agentAvatar, designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js'; import { agentAvatar, designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
import type { IndexEntry } from '../core/index.js'; import type { IndexEntry } from '../core/index.js';
@ -35,10 +36,18 @@ function snippet(text: string, max = 120): string {
export function renderActivityHtml(cwd: string): string { export function renderActivityHtml(cwd: string): string {
const config = loadConfig(cwd); const config = loadConfig(cwd);
const tasks = listTasks(cwd); const tasks = listTasks(cwd);
const messages = listMessages(cwd);
const done = tasks.filter((t) => t.status === 'done').sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); const done = tasks.filter((t) => t.status === 'done').sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
// Messaging lives on its own /messages page now — /activity is tasks-only.
const activityRows = [ const activityRows = [
...messages.map((m) => ({
at: m.createdAt,
html: `<article class="activity-row">
<span class="kind kind-message">message</span>
<div class="activity-main"><span class="id">${escapeHtml(m.id)}</span> ${escapeHtml(m.from || '?')} to ${escapeHtml(m.to || '?')}: ${escapeHtml(snippet(m.text))}</div>
<span class="state">${m.status === 'read' ? `read by ${escapeHtml(m.to || '?')}` : '<span class="unread-dot" aria-hidden="true"></span>unread'}</span>
</article>`,
})),
...tasks.map((t) => ({ ...tasks.map((t) => ({
at: t.updatedAt || t.createdAt, at: t.updatedAt || t.createdAt,
html: `<a class="activity-row" href="/tasks/${encodeURIComponent(t.id)}"> html: `<a class="activity-row" href="/tasks/${encodeURIComponent(t.id)}">
@ -86,8 +95,10 @@ export function renderActivityHtml(cwd: string): string {
.activity-row { display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:8px;align-items:start;color:inherit;text-decoration:none;border-top:1px solid var(--border);padding:8px 0; } .activity-row { display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:8px;align-items:start;color:inherit;text-decoration:none;border-top:1px solid var(--border);padding:8px 0; }
.activity-row:first-child { border-top:0;padding-top:0; } .activity-row:first-child { border-top:0;padding-top:0; }
.kind { font:10px/1.4 var(--font-mono);border-radius:999px;padding:1px 6px;border:1px solid var(--border);white-space:nowrap; } .kind { font:10px/1.4 var(--font-mono);border-radius:999px;padding:1px 6px;border:1px solid var(--border);white-space:nowrap; }
.kind-message { color:var(--accent);border-color:rgba(88,166,255,.32);background:rgba(88,166,255,.08); }
.kind-task { color:var(--status-review);border-color:rgba(210,153,34,.32);background:rgba(210,153,34,.08); } .kind-task { color:var(--status-review);border-color:rgba(210,153,34,.32);background:rgba(210,153,34,.08); }
.activity-main,.title { min-width:0;overflow-wrap:anywhere; } .activity-main,.title { min-width:0;overflow-wrap:anywhere; }
.unread-dot { display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-right:4px; }
.task-row { display:grid;grid-template-columns:82px minmax(0,1fr) 74px auto;gap:10px;align-items:center;color:inherit;text-decoration:none;border-top:1px solid var(--border);padding:8px 0; } .task-row { display:grid;grid-template-columns:82px minmax(0,1fr) 74px auto;gap:10px;align-items:center;color:inherit;text-decoration:none;border-top:1px solid var(--border);padding:8px 0; }
.task-row:first-child { border-top:0;padding-top:0; } .task-row:first-child { border-top:0;padding-top:0; }
.task-row:hover,.activity-row:hover { color:var(--text); } .task-row:hover,.activity-row:hover { color:var(--text); }

1995
src/server/board.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,43 +0,0 @@
import { appHeader } from '../ui-shared.js';
/**
* Fixed app header for board v2 the shared v2 header (ui-shared.appHeader)
* with the board marked active. The project cell is visual only (dropdown = v3);
* SSE ids (`sseStatus`, `sseLabel`) match the ported connection-status JS.
* Styling comes from ui-shared's appHeaderOnlyCss(), which boardV2Css() includes.
*/
export function headerHtml(projectName: string): string {
return appHeader(projectName, 'board');
}
/** Fullscreen splash overlay, shown until first data render (hard cap 1.5s). */
export function splashHtml(): string {
return `
<div id="b2-splash" class="b2-splash" aria-hidden="true">
<div class="b2-splash-inner">
<img src="/logo.svg" alt="agenthub" width="72" height="72" class="b2-splash-logo">
<div class="b2-splash-word">agenthub</div>
</div>
</div>`;
}
/**
* Inline script: hides the splash after the first successful refresh() or after
* a hard 1500ms cap, whichever comes first. Exposes window.__b2SplashDone() so
* the data layer can signal completion.
*/
export function splashJs(): string {
return `
(function () {
var done = false;
window.__b2SplashDone = function () {
if (done) return;
done = true;
var s = document.getElementById('b2-splash');
if (!s) return;
s.classList.add('b2-splash-hide');
setTimeout(function () { if (s.parentNode) s.parentNode.removeChild(s); }, 450);
};
setTimeout(window.__b2SplashDone, 1500); // hard cap — splash must never block
})();`;
}

View File

@ -1,897 +0,0 @@
/**
* Board v2 columns/cards/modals/data-layer: a faithful port of the v1 client
* JS (old src/server/board.ts, lines 9751460 and 17522032) with these
* deliberate changes:
* - v1 KPI metric cards + Lottie icons are gone; renderBoard() instead calls
* window.__b2UpdateKpis / __b2RenderThroughput (kpis.ts / sidebar.ts).
* - taskCard() adds the b2-working glow and the in-progress PROGRESS RING
* (elapsed vs median-done-duration estimate; amber when over estimate).
* - SSE task events additionally push a line into the live feed
* (window.__b2FeedPush, sidebar.ts).
* - The init sequence is NOT included here (see initJs below) so index.ts
* can run it after all module scripts are defined.
* Everything else (patchColumn reconcile, drag & drop, race-guarded moves,
* live console, modals, toasts, connection state) is byte-faithful to v1.
*/
export interface BoardColumnDef {
key: string;
label: string;
}
/** Column skeletons; mount points match the ported JS (data-cards/data-count). */
export function columnsHtml(columns: BoardColumnDef[]): string {
const cols = columns
.map(
(c) => ` <section class="column b2-glass" data-column="${c.key}" aria-label="${c.label}">
<header class="col-head"><span class="col-dot" aria-hidden="true"></span><span class="col-label">${c.label}</span><span class="col-count" data-count="${c.key}">0</span></header>
<div class="cards" data-cards="${c.key}"><div class="empty">none</div></div>
</section>`,
)
.join('\n');
return `<section class="board" id="board" aria-label="Task board">\n${cols}\n </section>`;
}
/** Modal markup (new task / delete confirm / task detail) + toast host, from v1. */
export function modalsHtml(): string {
return `
<div class="modal-backdrop" id="taskModal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="tmHeading">
<div class="modal-head">
<h2 id="tmHeading">New task</h2>
<button class="modal-x" id="tmClose" type="button" aria-label="Close">&times;</button>
</div>
<form id="tmForm" autocomplete="off">
<label class="modal-field">
<span class="modal-label">Title</span>
<input id="tmTitleInput" type="text" placeholder="What needs doing?" required maxlength="140" />
</label>
<label class="modal-field">
<span class="modal-label">Description</span>
<textarea id="tmDesc" rows="4" placeholder="Context, acceptance criteria, links… (optional)"></textarea>
</label>
<label class="modal-field modal-field-inline">
<span class="modal-label">Priority</span>
<select id="tmPriority">
<option value="low">low</option>
<option value="medium" selected>medium</option>
<option value="high">high</option>
<option value="critical">critical</option>
</select>
</label>
<p class="modal-note">Created unassigned the architect picks it up and delegates it.</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" id="tmCancel">Cancel</button>
<button type="submit" class="modal-create">Create task</button>
</div>
</form>
</div>
</div>
<div class="modal-backdrop" id="confirmModal" hidden>
<div class="modal confirm-modal" role="alertdialog" aria-modal="true" aria-labelledby="cfHeading" aria-describedby="cfBody">
<div class="modal-head">
<h2 id="cfHeading">Delete task</h2>
<button class="modal-x" id="cfClose" type="button" aria-label="Close">&times;</button>
</div>
<div class="confirm-body">
<p id="cfBody">This cannot be undone.</p>
</div>
<div class="modal-actions">
<button type="button" class="modal-cancel" id="cfCancel">Cancel</button>
<button type="button" class="modal-danger" id="cfConfirm">Delete</button>
</div>
</div>
</div>
<div class="modal-backdrop" id="detailModal" hidden>
<div class="modal detail-modal" role="dialog" aria-modal="true" aria-labelledby="dtHeading">
<div class="modal-head">
<h2 id="dtHeading">Task detail</h2>
<button class="modal-x" id="dtClose" type="button" aria-label="Close">&times;</button>
</div>
<div class="detail-body" id="dtBody"></div>
</div>
</div>
<div class="toasts" id="toasts" aria-live="polite"></div>`;
}
export interface ColumnsJsOpts {
columns: string[];
activeColumns: string[];
projectName: string;
}
/** The ported client JS (no init sequence — index.ts appends initJs last). */
export function columnsJs(opts: ColumnsJsOpts): string {
return `
var REFRESH_MS = 6000;
var TIMER_MS = 1000;
var COLUMNS = ${JSON.stringify(opts.columns)};
var ACTIVE_COLUMNS = ${JSON.stringify(opts.activeColumns)};
var PROJECT_NAME = ${JSON.stringify(opts.projectName)};
var lastTasks = [];
var openConsoles = {}; // taskId -> true when its in-card console is expanded
var estimatedMs = 3600000; // progress-ring estimate; recomputed per refresh
var CONN_CACHE_KEY = 'agenthub-connection-state';
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function compactDuration(ms) {
var s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) return s + 's';
var m = Math.floor(s / 60);
if (m < 60) return m + 'm';
var h = Math.floor(m / 60);
if (h < 48) return h + 'h';
return Math.floor(h / 24) + 'd';
}
function ago(iso) {
var t = Date.parse(iso);
if (isNaN(t)) return '';
return compactDuration(Date.now() - t) + ' ago';
}
async function getJSON(path) {
var res = await fetch(path, { cache: 'no-store', headers: { accept: 'application/json' } });
if (!res.ok) throw new Error(path + ' -> ' + res.status);
return res.json();
}
function taskPath(id) {
return '/tasks/' + encodeURIComponent(id);
}
function statusLabel(status) {
return String(status || 'open').replace(/_/g, ' ');
}
function hashColor(name) {
var colors = ['#0EA5E9', '#14B8A6', '#F59E0B', '#EF4444', '#8B5CF6', '#64748B'];
var h = 0;
var s = String(name || '');
for (var i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
return colors[Math.abs(h) % colors.length];
}
function agentSpec(name) {
var key = String(name || '').toLowerCase();
var map = {
claude: { color: '#D97757', initials: 'C', architect: true },
codex: { color: '#10A37F', initials: 'Cx' },
kimi: { color: '#7C3AED', initials: 'K' },
'windows-claude': { color: '#2563EB', initials: 'W' },
backyard: { color: '#64748B', initials: 'B' }
};
if (map[key]) return map[key];
var clean = key.replace(/[^a-z0-9]+/g, ' ').trim();
return {
color: hashColor(key),
initials: (clean ? clean.split(' ').map(function(p) { return p[0]; }).join('').slice(0, 2) : '?').toUpperCase()
};
}
function agentAvatar(name, role) {
if (!name) return '<span class="avatar" style="--agent-color:#64748B"><span class="avatar-core">?</span><span class="avatar-label">unassigned</span></span>';
var spec = agentSpec(name);
var isArchitect = spec.architect || String(role || '').toLowerCase() === 'architect';
return '<span class="avatar' + (isArchitect ? ' architect' : '') + '" style="--agent-color:' + esc(spec.color) + '">' +
'<span class="avatar-core">' + esc(spec.initials) + '</span>' +
'<span class="avatar-label">@' + esc(name) + '</span>' +
'</span>';
}
function reviewerBadge(name) {
if (!name) return '';
return '<span class="reviewer-wrap"><span class="reviewer-label">reviewed by</span>' + agentAvatar(name, 'reviewer') + '</span>';
}
function projectTag(title) {
var s = String(title || '').toLowerCase();
if (s.indexOf('win') >= 0 || s.indexOf('windows') >= 0) return 'windows';
if (s.indexOf('backend') >= 0 || s.indexOf('api') >= 0) return 'backend';
if (s.indexOf('magic') >= 0) return 'magic';
if (s.indexOf('agenthub') >= 0 || s.indexOf('board') >= 0 || s.indexOf('ui') >= 0) return 'agenthub';
return PROJECT_NAME;
}
function timerLabel(status, createdAt, updatedAt) {
var created = Date.parse(createdAt);
var updated = Date.parse(updatedAt || createdAt);
if (isNaN(created)) return '';
var key = String(status || 'open');
if (key === 'done' || key === 'cancelled') {
return 'total ' + compactDuration((isNaN(updated) ? Date.now() : updated) - created);
}
if (key === 'in_progress') return 'claimed ' + ago(updatedAt || createdAt);
if (key === 'review') return 'review ' + ago(updatedAt || createdAt);
return 'created ' + ago(createdAt);
}
function updateTimers() {
document.querySelectorAll('[data-timer]').forEach(function(el) {
el.textContent = timerLabel(el.dataset.status, el.dataset.created, el.dataset.updated);
});
// Progress rings on in-progress cards: elapsed vs estimated duration.
var est = Math.max(60000, estimatedMs);
document.querySelectorAll('[data-ring]').forEach(function(el) {
var claimed = Date.parse(el.getAttribute('data-claimed') || '');
if (isNaN(claimed)) return;
var elapsed = Math.max(0, Date.now() - claimed);
var pct = Math.min(1, elapsed / est);
var fill = el.querySelector('.b2-ring-fill');
if (fill) fill.style.strokeDashoffset = String((50.3 * (1 - pct)).toFixed(1));
var t = el.querySelector('[data-ring-time]');
if (t) t.textContent = compactDuration(elapsed) + ' / ~' + compactDuration(est);
el.classList.toggle('b2-over', elapsed > est);
});
}
function byStatus(status) {
return COLUMNS.indexOf(status) >= 0 ? status : 'open';
}
function taskCard(t) {
var status = byStatus(t.status);
var isOpen = openConsoles[t.id] ? true : false;
var live = status === 'in_progress';
// Console lives inside the card while a task is worked (in_progress = live)
// and stays available in review so you can see what the agent did.
var console = (live || status === 'review')
? '<div class="card-console-wrap">' +
'<button class="card-console-toggle" type="button" data-console-toggle="' + esc(t.id) + '" aria-expanded="' + (isOpen ? 'true' : 'false') + '">' +
(live ? '<span class="cc-dot" aria-hidden="true"></span>' : '') + (live ? 'live console' : 'agent console') +
'<span class="cc-chevron" aria-hidden="true">' + (isOpen ? '\\u25be' : '\\u25b8') + '</span>' +
'</button>' +
'<div class="card-console" data-console-for="' + esc(t.id) + '"' + (isOpen ? '' : ' hidden') + '>' +
'<div class="card-console-body" data-console-body="' + esc(t.id) + '"><div class="cc-empty">waiting for output…</div></div>' +
'</div>' +
'</div>'
: '';
// Progress ring on in-progress (work time) AND review cards (wait time) —
// same design, review variant in amber. Filled = elapsed vs median estimate.
var ringStatus = live ? 'work' : status === 'review' ? 'review' : '';
var ringSince = live ? (t.claimedAt || t.updatedAt || t.createdAt || '') : (t.updatedAt || t.createdAt || '');
var ring = ringStatus
? '<span class="b2-ring' + (ringStatus === 'review' ? ' b2-ring-review' : '') + '" data-ring data-claimed="' + esc(ringSince) + '">' +
'<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">' +
'<circle class="b2-ring-track" cx="10" cy="10" r="8" stroke-width="3"/>' +
'<circle class="b2-ring-fill" cx="10" cy="10" r="8" stroke-width="3" stroke-dasharray="50.3" stroke-dashoffset="50.3"/>' +
'</svg>' +
'<span class="b2-ring-time" data-ring-time></span>' +
'</span>'
: '';
return '<a class="card' + (live ? ' b2-working' : '') + '" draggable="true" href="/tasks/' + encodeURIComponent(t.id) + '"' +
' data-id="' + esc(t.id) + '" data-status="' + esc(status) + '" data-assigned="' + esc(t.assignedTo || '') + '" data-reviewer="' + esc(t.reviewer || '') + '">' +
'<div class="card-top">' +
'<span class="id">' + esc(t.id) + '</span>' +
'<span class="card-top-right">' +
'<span class="pill status-pill status-' + esc(status) + '">' + esc(statusLabel(status)) + '</span>' +
'<button class="card-del" type="button" data-del="' + esc(t.id) + '" title="Delete task permanently" aria-label="Delete ' + esc(t.id) + '">' +
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14"/></svg>' +
'</button>' +
'</span>' +
'</div>' +
'<h3 class="title">' + esc(t.title) + '</h3>' +
'<div class="meta-row">' +
(status === 'review' && t.reviewer
? reviewerBadge(t.reviewer)
: agentAvatar(t.assignedTo, t.role)) +
'<span class="pill timer-badge" data-timer data-status="' + esc(status) + '" data-created="' + esc(t.createdAt) + '" data-updated="' + esc(t.updatedAt) + '">' +
esc(timerLabel(status, t.createdAt, t.updatedAt)) +
'</span>' +
'</div>' +
ring +
console +
'</a>';
}
// Median real duration of recently done tasks — the progress ring's "typical
// duration" estimate. Fallback 60 min (mirrors the budget-service live cap).
function estimateDurationMs(tasks) {
var ds = [];
(tasks || []).forEach(function(t) {
if (t.status !== 'done') return;
var start = Date.parse(t.claimedAt || t.createdAt || '');
var end = Date.parse(t.updatedAt || '');
if (!isNaN(start) && !isNaN(end) && end > start) ds.push(end - start);
});
if (!ds.length) return 3600000;
ds.sort(function(a, b) { return a - b; });
return ds[Math.floor(ds.length / 2)];
}
// Signature of everything that changes a card's rendered CONTENT — but NOT
// its console open/closed state (that lives in the DOM + openConsoles and
// must survive a patch). Identical signature ⇒ the card node is left alone.
function cardSig(t) {
return [byStatus(t.status), t.title || '', t.assignedTo || '', t.reviewer || '', t.role || '', t.createdAt || '', t.updatedAt || ''].join('\\u0001');
}
function cardNode(t, sig, isNew) {
var tmp = document.createElement('div');
tmp.innerHTML = taskCard(t);
var node = tmp.firstElementChild;
if (!node) return null;
node.setAttribute('data-sig', sig);
if (isNew) {
node.classList.add('card-new');
// One-shot marker: drop it once the enter animation has played, so a
// later reorder (insertBefore) can never replay the animation.
setTimeout(function() { node.classList.remove('card-new'); }, 300);
}
return node;
}
// Reconcile ONE column's DOM against its desired task list WITHOUT a full
// innerHTML= replace (which re-created every node on every event → board
// twitch + card overlap from the global enter animation). Only genuinely
// new/changed cards are (re)built; untouched cards are kept and merely
// reordered, so open live consoles + hover/animation state survive.
function patchColumn(cardsEl, list) {
if (!list.length) {
if (cardsEl.children.length !== 1 || !cardsEl.querySelector('.empty')) {
cardsEl.innerHTML = '<div class="empty">none</div>';
}
return;
}
var placeholder = cardsEl.querySelector('.empty');
if (placeholder) placeholder.remove();
var existing = {};
cardsEl.querySelectorAll('.card[data-id]').forEach(function(node) {
existing[node.getAttribute('data-id')] = node;
});
var seen = {};
var prev = null;
list.forEach(function(t) {
seen[t.id] = true;
var sig = cardSig(t);
var old = existing[t.id];
var node;
if (old && old.getAttribute('data-sig') === sig) {
node = old; // unchanged — keep node + its console
} else {
node = cardNode(t, sig, !old); // new-to-this-column ⇒ enter animation
if (old) old.remove(); // drop the stale version
if (!node) return;
}
// Place node immediately after the previous card so DOM order == list.
var anchor = prev ? prev.nextSibling : cardsEl.firstChild;
if (node !== anchor) cardsEl.insertBefore(node, anchor);
prev = node;
});
// Remove cards that left this column (moved elsewhere or were deleted).
Object.keys(existing).forEach(function(id) {
if (!seen[id]) existing[id].remove();
});
}
function renderBoard(tasks) {
lastTasks = tasks || [];
estimatedMs = estimateDurationMs(lastTasks);
var byCol = {};
ACTIVE_COLUMNS.forEach(function(k) { byCol[k] = []; });
lastTasks.forEach(function(t) {
var key = byStatus(t.status);
if (byCol[key]) byCol[key].push(t);
});
ACTIVE_COLUMNS.forEach(function(k) {
var list = byCol[k];
var cardsEl = document.querySelector('[data-cards="' + k + '"]');
var count = document.querySelector('[data-count="' + k + '"]');
if (count) count.textContent = String(list.length);
if (!cardsEl) return;
patchColumn(cardsEl, list);
});
updateTimers();
reapplyConsoles();
if (window.__b2UpdateKpis) {
window.__b2UpdateKpis(lastTasks, function(name) { return agentSpec(name).color; });
}
if (window.__b2RenderThroughput) window.__b2RenderThroughput(lastTasks);
}
// ── In-card live agent console ──────────────────────────────────────────
function consoleLineHtml(e) {
var lvl = String(e.level || 'info');
var ts = '';
try { ts = new Date(e.ts).toLocaleTimeString(); } catch (_) {}
return '<div class="cc-line level-' + esc(lvl) + '">' +
(ts ? '<span class="cc-ts">' + esc(ts) + '</span>' : '') +
(e.agent ? '<span class="cc-agent">' + esc(e.agent) + '</span>' : '') +
'<span class="cc-text">' + esc(e.text) + '</span>' +
'</div>';
}
function renderConsole(id, entries) {
var body = document.querySelector('[data-console-body="' + id + '"]');
if (!body) return;
if (!entries || !entries.length) { body.innerHTML = '<div class="cc-empty">waiting for output…</div>'; return; }
body.innerHTML = entries.map(consoleLineHtml).join('');
body.scrollTop = body.scrollHeight;
}
function detailActivityHtml(a) {
var when = '';
try { when = a.at ? ago(a.at) : ''; } catch (_) {}
return '<article class="detail-activity-row">' +
'<span class="detail-when">' + esc(when) + '</span>' +
'<span class="detail-kind">' + esc(a.kind || '') + '</span>' +
'<div class="detail-summary">' + esc(a.summary || '') + '</div>' +
'<span class="detail-actor">' + esc(a.actor || '') + '</span>' +
'</article>';
}
function renderTaskDetailModal(id, detail, activity) {
var body = document.getElementById('dtBody');
var heading = document.getElementById('dtHeading');
if (!body) return;
var task = detail && detail.task ? detail.task : {};
var desc = String(detail && detail.body ? detail.body : '').trim();
if (heading) heading.textContent = task.id ? task.id : 'Task detail';
var status = byStatus(task.status);
var sections = [
'<div class="detail-topline"><span class="id">' + esc(task.id || id) + '</span><span class="pill status-pill status-' + esc(status) + '">' + esc(statusLabel(status)) + '</span>' + (task.assignedTo ? agentAvatar(task.assignedTo, task.role) : '') + '</div>',
'<h1 class="detail-title">' + esc(task.title || id) + '</h1>'
];
if (desc) {
sections.push('<section class="detail-section"><h3>Description</h3><pre class="detail-description">' + esc(desc) + '</pre></section>');
}
if (activity && activity.length) {
sections.push('<section class="detail-section"><h3>Activity</h3><div class="detail-activity">' + activity.map(detailActivityHtml).join('') + '</div></section>');
}
body.innerHTML = sections.join('');
}
async function openTaskDetail(id) {
var modal = document.getElementById('detailModal');
var body = document.getElementById('dtBody');
if (!modal || !body) return;
modal.hidden = false;
body.innerHTML = '<div class="empty">loading…</div>';
try {
var results = await Promise.all([
getJSON(taskPath(id)),
getJSON(taskPath(id) + '/activity')
]);
renderTaskDetailModal(id, results[0], results[1] || []);
} catch (err) {
body.innerHTML = '<div class="detail-error">Could not load ' + esc(id) + '.</div>';
}
}
function closeTaskDetail() {
var modal = document.getElementById('detailModal');
if (modal) modal.hidden = true;
}
async function loadConsole(id) {
try {
var data = await getJSON(taskPath(id) + '/log');
renderConsole(id, data && data.log ? data.log : []);
} catch (_) {}
}
function appendConsoleLine(id, entry) {
if (!openConsoles[id]) return;
var body = document.querySelector('[data-console-body="' + id + '"]');
if (!body) return;
var empty = body.querySelector('.cc-empty');
if (empty) body.innerHTML = '';
body.insertAdjacentHTML('beforeend', consoleLineHtml(entry));
body.scrollTop = body.scrollHeight;
}
// Re-open any consoles that were expanded before a re-render (cards rebuild
// their innerHTML, so the panel state must be re-applied + reloaded).
function reapplyConsoles() {
Object.keys(openConsoles).forEach(function(id) {
if (!openConsoles[id]) return;
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
if (panel) { panel.hidden = false; loadConsole(id); }
});
}
function setConn(state, label) {
var el = document.getElementById('sseStatus');
var text = document.getElementById('sseLabel');
if (!el || !text) return;
el.className = 'sse-status ' + (state === 'ok' ? '' : state === 'stale' ? 'stale' : 'down');
text.textContent = label || (state === 'ok' ? 'connected' : state === 'stale' ? 'connecting' : 'offline');
try { sessionStorage.setItem(CONN_CACHE_KEY, JSON.stringify({ state: state, label: text.textContent, at: Date.now() })); } catch (_) {}
}
function restoreConn() {
try {
var cached = JSON.parse(sessionStorage.getItem(CONN_CACHE_KEY) || 'null');
if (cached && cached.at && Date.now() - cached.at < 30000) {
setConn(cached.state || 'stale', cached.label || 'connected');
}
} catch (_) {}
}
function applyProjectName(name) {
PROJECT_NAME = name || PROJECT_NAME;
var el = document.getElementById('projectName');
if (el) el.textContent = PROJECT_NAME;
}
async function loadStatusMeta() {
try {
var status = await getJSON('/status');
var body = String(status && status.body ? status.body : '');
var m = body.match(/^#\\s+AgentHub\\s+[\\u2014-]\\s+(.+)$/m) || body.match(/^#\\s+Project\\s+Status[^\\S\\n]*[:\\-][^\\S\\n]*(.+)$/m);
var name = m && m[1] ? m[1].trim() : '';
// Guard: never let a summary line (long / contains a period) become the name.
applyProjectName(name && name.length <= 48 && name.indexOf('.') < 0 ? name : PROJECT_NAME);
} catch (_) {
applyProjectName(PROJECT_NAME);
}
}
async function refresh() {
try {
var tasks = await getJSON('/tasks');
renderBoard(tasks);
setConn('ok', eventSourceReady ? 'connected' : 'polling');
if (window.__b2SplashDone) window.__b2SplashDone();
} catch (_) {
setConn('down', 'offline');
}
}
var eventSourceReady = false;
var eventSource = null;
var fallbackPollTimer = null;
function startFallbackPoll() {
if (fallbackPollTimer) return;
fallbackPollTimer = setInterval(refresh, REFRESH_MS);
}
function stopFallbackPoll() {
if (!fallbackPollTimer) return;
clearInterval(fallbackPollTimer);
fallbackPollTimer = null;
}
// Compact one-liner for the sidebar live feed (defensive about payload shape).
function feedFromEvent(d) {
if (!window.__b2FeedPush) return;
var id = (d && (d.id || d.taskId || (d.task && d.task.id))) || 'task';
var status = (d && (d.status || d.to || (d.task && d.task.status))) || '';
var agent = (d && (d.agent || d.assignedTo)) || '';
window.__b2FeedPush('<span class="pulse">●</span><span>' + esc(id) + (status ? ' → ' + esc(status) : ' updated') + (agent ? ' · @' + esc(agent) : '') + '</span>');
}
function connectEvents() {
if (!('EventSource' in window)) {
setConn('stale', 'polling');
startFallbackPoll();
return;
}
if (eventSource) eventSource.close();
var source = new EventSource('/events');
eventSource = source;
source.onopen = function() {
eventSourceReady = true;
stopFallbackPoll();
setConn('ok', 'connected');
refresh();
refreshBudget();
};
source.onmessage = function(ev) {
eventSourceReady = true;
stopFallbackPoll();
setConn('ok', 'connected');
// Only task lifecycle changes affect the board. message / ask / decision /
// memory / handoff / agent-presence events must NOT trigger a board
// rerender — the old unconditional refresh() on every change was the root
// cause of the board twitch + card overlap. The budget panel has its own
// diff + throttle (shouldRenderDonut / FLIP bars), so it can refresh on
// any change without churn.
var type = '';
var data = null;
try { data = JSON.parse(ev && ev.data); type = (data || {}).type || ''; } catch (_) {}
if (type === 'task') { refresh(); feedFromEvent(data); }
refreshBudget();
};
source.onerror = function() {
eventSourceReady = false;
setConn('stale', 'reconnecting');
startFallbackPoll();
};
// Named task-log events feed the in-card live console without a re-render.
source.addEventListener('task-log', function(ev) {
try {
var p = JSON.parse(ev.data);
if (p && p.taskId) appendConsoleLine(p.taskId, p);
} catch (_) {}
});
}
function closeEvents() {
stopFallbackPoll();
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
window.addEventListener('pagehide', closeEvents);
window.addEventListener('beforeunload', closeEvents);
window.addEventListener('pageshow', function() {
refresh();
refreshBudget();
if (!eventSource) connectEvents();
});
document.addEventListener('visibilitychange', function() {
if (document.hidden) return;
refresh();
refreshBudget();
if (!eventSource) connectEvents();
});
window.addEventListener('focus', function() {
refresh();
refreshBudget();
});
// ── Toasts ──────────────────────────────────────────────────────────────
function toast(msg, opts) {
opts = opts || {};
var wrap = document.getElementById('toasts');
if (!wrap) return;
var el = document.createElement('div');
el.className = 'toast' + (opts.error ? ' err' : '');
el.innerHTML = '<span class="dot" aria-hidden="true"></span><span>' + esc(msg) + '</span>';
wrap.appendChild(el);
setTimeout(function() {
el.classList.add('out');
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 240);
}, opts.ms || 3200);
}
// ── Mutations (create / assign / move) ──────────────────────────────────
async function postTask(body) {
var res = await fetch('/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error('create failed (' + res.status + ')');
return res.json();
}
async function patchTask(id, body) {
var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) {
var msg = 'update failed (' + res.status + ')';
try { var j = await res.json(); if (j && j.error) msg = j.error; } catch (_) {}
throw new Error(msg);
}
return res.json();
}
function flashCard(id) {
var el = document.querySelector('.card[data-id="' + id + '"]');
if (!el) return;
el.classList.add('flash');
setTimeout(function() { el.classList.remove('flash'); }, 900);
}
// Derive the addressed agent from a task title's "name:" prefix (delegation
// convention), matched case-insensitively against the known roster.
function agentFromTitle(id) {
var t = (lastTasks || []).find(function(x) { return x.id === id; });
if (!t || !t.title) return '';
var m = String(t.title).match(/^([A-Za-z][A-Za-z0-9_-]*)\\s*:/);
if (!m) return '';
var name = m[1].toLowerCase();
var hit = (AGENTS || []).find(function(a) { return String(a.name).toLowerCase() === name; });
return hit ? hit.name : '';
}
async function onDropColumn(id, status, assigned) {
var card = document.querySelector('.card[data-id="' + id + '"]');
var from = card ? card.dataset.status : '';
if (from === status) return;
try {
if (status === 'in_progress') {
// Claiming needs an agent: use the current assignee, else the one named
// in the title, else mark it as a manual board claim.
var titledAgent = assigned ? '' : agentFromTitle(id);
var agent = assigned || titledAgent || 'manual';
await patchTask(id, { status: 'in_progress', assignedTo: agent });
toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (manual)' : ''));
} else if (status === 'review') {
var reviewed = await patchTask(id, { status: 'review' });
toast(id + ' \\u2192 review' + (reviewed && reviewed.reviewer ? ' \\u00b7 reviewed by @' + reviewed.reviewer : ''));
} else if (status === 'open') {
await patchTask(id, { status: 'open' });
toast(id + ' \\u2192 reopened');
} else { return; }
await refresh(); await refreshBudget(); flashCard(id);
} catch (e) { toast(e.message || 'move failed', { error: true }); }
}
// ── Drag & drop wiring (event delegation) ───────────────────────────────
// Drag a CARD onto a COLUMN to change its status (in_progress needs an agent).
var dragKind = null, dragId = null, lastTarget = null;
function dropTargetOf(e) {
var n = e.target;
if (!n || !n.closest) return null;
if (dragKind === 'card') return n.closest('.column');
return null;
}
document.addEventListener('dragstart', function(e) {
// Don't start a card drag when interacting with the in-card console.
if (e.target.closest && e.target.closest('.card-console-wrap')) { e.preventDefault(); return; }
var card = e.target.closest && e.target.closest('.card');
if (card) {
dragKind = 'card'; dragId = card.dataset.id;
card.classList.add('dragging');
if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', dragId); } catch (_) {} }
}
});
document.addEventListener('dragend', function(e) {
var el = e.target.closest && e.target.closest('.card');
if (el) {
el.classList.remove('dragging');
// Guard the trailing click so a drag doesn't also open the task detail.
el.classList.add('just-dragged');
setTimeout(function() { el.classList.remove('just-dragged'); }, 350);
}
if (lastTarget) { lastTarget.classList.remove('drop-active'); lastTarget = null; }
dragKind = null; dragId = null;
});
document.addEventListener('dragover', function(e) {
if (!dragKind) return;
var tgt = dropTargetOf(e);
if (!tgt) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
if (lastTarget && lastTarget !== tgt) lastTarget.classList.remove('drop-active');
tgt.classList.add('drop-active');
lastTarget = tgt;
});
document.addEventListener('dragleave', function(e) {
var tgt = dropTargetOf(e);
if (tgt && e.relatedTarget && !tgt.contains(e.relatedTarget)) {
tgt.classList.remove('drop-active');
if (lastTarget === tgt) lastTarget = null;
}
});
document.addEventListener('drop', function(e) {
if (!dragKind) return;
var tgt = dropTargetOf(e);
if (!tgt) return;
e.preventDefault();
tgt.classList.remove('drop-active');
var card = document.querySelector('.card[data-id="' + dragId + '"]');
var assigned = card ? (card.dataset.assigned || '') : '';
onDropColumn(dragId, tgt.dataset.column, assigned);
});
// A drag that ends in a real drop still fires a click on the <a> card — swallow it.
document.addEventListener('click', function(e) {
var card = e.target.closest && e.target.closest('.card');
if (card && card.classList.contains('just-dragged')) { e.preventDefault(); card.classList.remove('just-dragged'); }
}, true);
document.addEventListener('click', function(e) {
if (e.target.closest && e.target.closest('.card-del, [data-console-toggle], .card-console, .card-console-wrap')) return;
var card = e.target.closest && e.target.closest('.card');
if (!card || card.classList.contains('just-dragged')) return;
e.preventDefault();
openTaskDetail(card.dataset.id);
});
// ── Delete a task (permanent) ───────────────────────────────────────────
async function deleteTask(id) {
try {
var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'DELETE', headers: { accept: 'application/json' } });
if (!res.ok) {
var msg = 'delete failed (' + res.status + ')';
try { var j = await res.json(); if (j && j.error) msg = j.error; } catch (_) {}
throw new Error(msg);
}
toast(id + ' deleted');
await refresh(); await refreshBudget();
} catch (e) { toast(e.message || 'delete failed', { error: true }); }
}
// Themed confirm dialog (replaces window.confirm). Resolves true on confirm.
function confirmModal(opts) {
opts = opts || {};
return new Promise(function(resolve) {
var modal = document.getElementById('confirmModal');
if (!modal) { resolve(window.confirm(opts.bodyText || 'Are you sure?')); return; }
var heading = document.getElementById('cfHeading');
var body = document.getElementById('cfBody');
var okBtn = document.getElementById('cfConfirm');
var cancelBtn = document.getElementById('cfCancel');
var closeBtn = document.getElementById('cfClose');
if (heading) heading.textContent = opts.title || 'Confirm';
if (body) body.innerHTML = opts.bodyHtml || esc(opts.bodyText || '');
if (okBtn) okBtn.textContent = opts.confirmLabel || 'Confirm';
var done = false;
function cleanup(result) {
if (done) return; done = true;
modal.hidden = true;
okBtn && okBtn.removeEventListener('click', onOk);
cancelBtn && cancelBtn.removeEventListener('click', onCancel);
closeBtn && closeBtn.removeEventListener('click', onCancel);
modal.removeEventListener('click', onBackdrop);
document.removeEventListener('keydown', onKey);
resolve(result);
}
function onOk() { cleanup(true); }
function onCancel() { cleanup(false); }
function onBackdrop(e) { if (e.target === modal) cleanup(false); }
function onKey(e) { if (e.key === 'Escape') cleanup(false); }
okBtn && okBtn.addEventListener('click', onOk);
cancelBtn && cancelBtn.addEventListener('click', onCancel);
closeBtn && closeBtn.addEventListener('click', onCancel);
modal.addEventListener('click', onBackdrop);
document.addEventListener('keydown', onKey);
modal.hidden = false;
if (okBtn) setTimeout(function() { okBtn.focus(); }, 20);
});
}
// Delete button lives inside the card <a> — stop the navigation + confirm.
document.addEventListener('click', function(e) {
var del = e.target.closest && e.target.closest('.card-del');
if (!del) return;
e.preventDefault(); e.stopPropagation();
var id = del.dataset.del;
if (!id) return;
confirmModal({
title: 'Delete ' + id + '?',
bodyHtml: 'This permanently removes <b>' + esc(id) + '</b> — its file and index entry. This cannot be undone.',
confirmLabel: 'Delete task',
}).then(function(ok) { if (ok) deleteTask(id); });
});
// Clicks/selection inside the console body must not navigate to the task page.
document.addEventListener('click', function(e) {
if (e.target.closest && e.target.closest('.card-console')) e.preventDefault();
});
// Live-console toggle lives inside the card <a> — stop the navigation.
document.addEventListener('click', function(e) {
var tog = e.target.closest && e.target.closest('[data-console-toggle]');
if (!tog) return;
e.preventDefault(); e.stopPropagation();
var id = tog.getAttribute('data-console-toggle');
var willOpen = !openConsoles[id];
openConsoles[id] = willOpen;
tog.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = willOpen ? '\\u25be' : '\\u25b8';
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
if (panel) panel.hidden = !willOpen;
if (willOpen) loadConsole(id);
});
// ── Task-detail modal ──────────────────────────────────────────────────
(function() {
var modal = document.getElementById('detailModal');
if (!modal) return;
var close = document.getElementById('dtClose');
if (close) close.addEventListener('click', closeTaskDetail);
modal.addEventListener('click', function(e) { if (e.target === modal) closeTaskDetail(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && !modal.hidden) closeTaskDetail(); });
})();
// ── New-task modal ──────────────────────────────────────────────────────
(function() {
var btn = document.getElementById('newTaskBtn');
var modal = document.getElementById('taskModal');
var form = document.getElementById('tmForm');
if (!btn || !modal || !form) return;
var titleInput = document.getElementById('tmTitleInput');
function openModal() {
modal.hidden = false;
btn.setAttribute('aria-expanded', 'true');
if (titleInput) setTimeout(function() { titleInput.focus(); }, 20);
}
function closeModal() {
modal.hidden = true;
btn.setAttribute('aria-expanded', 'false');
form.reset();
}
btn.addEventListener('click', openModal);
document.getElementById('tmClose').addEventListener('click', closeModal);
document.getElementById('tmCancel').addEventListener('click', closeModal);
modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && !modal.hidden) closeModal(); });
form.addEventListener('submit', async function(e) {
e.preventDefault();
var title = (titleInput.value || '').trim();
if (!title) return;
var description = (document.getElementById('tmDesc').value || '').trim();
var priority = document.getElementById('tmPriority').value || undefined;
try {
// No assignee/role: the task lands open in the architect's lap to route.
var task = await postTask({ title: title, description: description, priority: priority });
toast(task.id + ' created');
closeModal();
await refresh(); await refreshBudget(); flashCard(task.id);
} catch (err) { toast(err.message || 'create failed', { error: true }); }
});
})();
`;
}
/**
* Init sequence MUST run after every module script above is defined
* (refreshBudget comes from v1Budget.ts, __b2UpdateKpis from kpis.ts, ).
* index.ts appends this as the last chunk of the single inline script.
*/
export function initJs(): string {
return `
loadStatusMeta();
restoreConn();
loadAgents();
refresh();
refreshBudget();
connectEvents();
setInterval(updateTimers, TIMER_MS);
setInterval(refreshBudget, 3000);
`;
}

View File

@ -1,55 +0,0 @@
import { boardV2Css } from './styles.js';
import { headerHtml, splashHtml, splashJs } from './chrome.js';
import { kpiSkeletonHtml, kpiJs } from './kpis.js';
import { sidebarHtml, sidebarJs } from './sidebar.js';
import { columnsHtml, columnsJs, modalsHtml, initJs, type BoardColumnDef } from './columns.js';
import { v1BudgetJs } from './v1Budget.js';
/** All statuses the data layer understands (order = board semantics). */
export const BOARD_COLUMNS: BoardColumnDef[] = [
{ key: 'open', label: 'Open' },
{ key: 'in_progress', label: 'In Progress' },
{ key: 'review', label: 'Review' },
{ key: 'done', label: 'Done' },
{ key: 'cancelled', label: 'Cancelled' },
];
/** The three lanes actually rendered as columns. */
export const ACTIVE_COLUMNS: BoardColumnDef[] = BOARD_COLUMNS.slice(0, 3);
/**
* Board v2: glass dashboard rendered once at server start; all data arrives
* via same-origin fetches + SSE. The single inline script is ordered so the
* init sequence (initJs) runs after every module's definitions exist.
*/
export function renderBoardHtml(projectName = 'AgentHub Project'): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>agenthub Board</title>
<link rel="icon" href="/logo.svg" type="image/svg+xml">
<style>${boardV2Css()}</style>
</head>
<body class="b2">
${splashHtml()}
${headerHtml(projectName)}
<main class="b2-body">
<div class="b2-main">
${kpiSkeletonHtml()}
${columnsHtml(ACTIVE_COLUMNS)}
</div>
${sidebarHtml()}
</main>
${modalsHtml()}
<script>${splashJs()}</script>
<script>${columnsJs({
columns: BOARD_COLUMNS.map((c) => c.key),
activeColumns: ACTIVE_COLUMNS.map((c) => c.key),
projectName,
})}${kpiJs()}${sidebarJs(v1BudgetJs)}${initJs()}</script>
</body>
</html>
`;
}

View File

@ -1,101 +0,0 @@
import {
capChips, doneStats, backlogSeries, areaPath, laneChips, dayStart,
} from './viewmodel.js';
/** Static skeleton: four KPI cards; values are filled client-side. */
export function kpiSkeletonHtml(): string {
return `
<section class="b2-kpis" aria-label="Kennzahlen">
<div class="b2-kpi b2-glass" id="kpiOpen">
<div class="b2-lbl">Open</div>
<div class="b2-val"><span data-k="count"></span> <small data-k="total"></small></div>
<div class="b2-miniarea" data-k="area"></div>
</div>
<div class="b2-kpi b2-glass" id="kpiInProgress">
<div class="b2-lbl">In Progress</div>
<div class="b2-val"><span data-k="count"></span></div>
<div class="b2-chips" data-k="chips"></div>
</div>
<div class="b2-kpi b2-glass" id="kpiReview">
<div class="b2-lbl">Review</div>
<div class="b2-val"><span data-k="count"></span></div>
<div class="b2-chips" data-k="chips"></div>
</div>
<div class="b2-kpi b2-glass" id="kpiDone">
<div class="b2-lbl">Done</div>
<div class="b2-val"><span data-k="count"></span> <small data-k="total"></small></div>
<div class="b2-cap-r" data-k="cap"></div>
<div class="b2-pbar"><i data-k="bar"></i></div>
</div>
</section>`;
}
/**
* Inline script: injects the pure viewmodel helpers (toString) plus the shared
* DAY_MS/dayStart they reference, and defines window.__b2UpdateKpis(tasks,
* agentColor), called by the data layer on every refresh. agentColor(name)
* comes from the columns module (existing per-agent palette).
*/
export function kpiJs(): string {
return `
var DAY_MS = 24 * 3600 * 1000;
${dayStart.toString()}
${capChips.toString()}
${doneStats.toString()}
${backlogSeries.toString()}
${areaPath.toString()}
${laneChips.toString()}
window.__b2UpdateKpis = function (tasks, agentColor) {
var kpiPrev = window.__b2KpiPrev || (window.__b2KpiPrev = {});
function flashKpi(card, value) {
if (kpiPrev[card] !== undefined && kpiPrev[card] !== value) {
var cardEl = document.getElementById(card);
if (cardEl) { cardEl.classList.remove('metric-flash'); void cardEl.offsetWidth; cardEl.classList.add('metric-flash'); }
}
kpiPrev[card] = value;
}
function setK(card, key, fn) {
var el = document.querySelector('#' + card + ' [data-k="' + key + '"]');
if (el) fn(el);
}
// Open
var open = tasks.filter(function (t) { return t.status === 'open'; });
setK('kpiOpen', 'count', function (el) { el.textContent = String(open.length); });
flashKpi('kpiOpen', String(open.length));
setK('kpiOpen', 'total', function (el) {
el.textContent = '/ ' + tasks.filter(function (t) { return t.status !== 'cancelled'; }).length;
});
setK('kpiOpen', 'area', function (el) {
var p = areaPath(backlogSeries(tasks, 14), 120, 26);
el.innerHTML = '<svg width="100%" height="26" viewBox="0 0 120 26" preserveAspectRatio="none">'
+ '<path fill="rgba(148,163,184,.18)" d="' + p.area + '"/>'
+ '<path class="b2-spark" fill="none" stroke="#94a3b8" stroke-width="1.5" d="' + p.line + '"/></svg>';
});
// In Progress + Review chips (max 3, then +n)
['in_progress', 'review'].forEach(function (status) {
var card = status === 'review' ? 'kpiReview' : 'kpiInProgress';
var chips = laneChips(tasks, status);
var capped = capChips(chips, 3);
setK(card, 'count', function (el) { el.textContent = String(chips.length); });
flashKpi(card, String(chips.length));
setK(card, 'chips', function (el) {
el.innerHTML = capped.visible.map(function (c) {
var color = agentColor ? agentColor(c.name) : '#a5b4fc';
var initial = c.name.charAt(0).toUpperCase();
var time = c.minutes == null ? '' : (status === 'review' ? 'prüft ' : '') + c.minutes + 'm';
var dot = status === 'in_progress' ? '<span class="b2-cdot"></span>' : '';
return '<span class="b2-achip"><i style="background:' + color + '">' + initial + '</i>' + dot + time + '</span>';
}).join('') + (capped.hidden > 0 ? '<span class="b2-achip b2-more">+' + capped.hidden + '</span>' : '');
});
});
// Done
var ds = doneStats(tasks);
setK('kpiDone', 'count', function (el) { el.textContent = String(ds.done); });
flashKpi('kpiDone', String(ds.done));
setK('kpiDone', 'total', function (el) { el.textContent = '/ ' + ds.total; });
setK('kpiDone', 'cap', function (el) {
el.textContent = ds.pct + '% · +' + ds.doneThisWeek + ' diese Woche';
});
setK('kpiDone', 'bar', function (el) { el.style.width = ds.pct + '%'; });
};`;
}

View File

@ -1,65 +0,0 @@
import { throughputSeries, areaPath, dayStart } from './viewmodel.js';
/**
* Sidebar skeleton: budget card + live feed card. The budget card has no tab
* bar of its own the Token/Kosten/Verlauf tabs live inside the donut card
* (rendered client-side by the ported v1 budget JS in v1Budget.ts). The
* subhead keeps the v1 mount points: [data-budget-mode] (Session/Gesamt),
* #budgetTotal, #budgetReset; #budgetRows is where the donut card mounts.
*/
export function sidebarHtml(): string {
return `
<aside class="b2-side">
<div class="b2-panel b2-glass" id="b2Budget">
<div class="b2-subhead">
<h6>Budget</h6>
<span class="seg" aria-label="Token insight range">
<button type="button" data-budget-mode="session" class="active">Session</button>
<button type="button" data-budget-mode="total">Gesamt</button>
</span>
<span class="total" id="budgetTotal"></span>
<button type="button" class="reset-btn" id="budgetReset">Reset</button>
</div>
<div id="budgetRows"><div class="budget-empty">no agent activity yet</div></div>
</div>
<div class="b2-panel b2-glass">
<h6>Live</h6>
<div class="b2-feed" id="b2Feed"></div>
</div>
</aside>`;
}
/**
* Inline script: throughput area chart renderer (used by the Verlauf tab in
* the donut card) + 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()}
window.__b2RenderThroughput = function (tasks) {
var box = document.getElementById('b2Throughput');
var tip = document.getElementById('b2ThroughputTip');
if (!box) return; // Verlauf tab not mounted right now — nothing to update
var s = throughputSeries(tasks, 14);
var p = areaPath(s, 220, 80);
box.innerHTML = '<svg width="100%" height="80" viewBox="0 0 220 80" preserveAspectRatio="none">'
+ '<path fill="rgba(56,189,248,.25)" d="' + p.area + '"/>'
+ '<path class="b2-spark" fill="none" stroke="#38bdf8" stroke-width="1.6" d="' + p.line + '"/></svg>';
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}`;
}

View File

@ -1,430 +0,0 @@
/**
* Board v2 design system: glass dashboard on a deep gradient.
* Everything is emitted by boardV2Css() and inlined into the page <style>.
* The header CSS is shared with all pages and comes from ui-shared
* (appHeaderOnlyCss) this file only adds the board-specific components.
*
* Naming: `b2-` prefix for new v2 components (kpis, sidebar tabs,
* feed, splash, progress ring). Interaction-critical components ported from
* v1 (cards, columns, console, modals, toasts, budget internals) keep their
* v1 class names so the ported client JS keeps working unchanged they read
* the same CSS variables, which are redefined here with the v2 glass values.
*/
import { appHeaderOnlyCss } from '../ui-shared.js';
export function boardV2Css(): string {
return appHeaderOnlyCss() + `
:root {
/* v2 tokens */
--b2-bg: #090c18;
--b2-surface: rgba(255,255,255,.045);
--b2-raised: rgba(255,255,255,.055);
--b2-border: rgba(255,255,255,.09);
--b2-text: #eef1f8;
--b2-muted: #8fa0c6;
--b2-dim: #5b6a8c;
--b2-accent: #38bdf8;
--b2-violet: #8b5cf6;
--b2-green: #34d399;
--b2-amber: #fbbf24;
--b2-open: #8b949e;
/* v1 aliases (ported components read these) */
--bg: var(--b2-bg);
--surface: var(--b2-surface);
--raised: var(--b2-raised);
--border: var(--b2-border);
--text: var(--b2-text);
--muted: var(--b2-muted);
--accent: var(--b2-accent);
--green: var(--b2-green);
--open: #8B949E;
--in_progress: #38bdf8;
--review: #fbbf24;
--done: #34d399;
--cancelled: #6E7681;
--danger: #F85149;
--mono: ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--sans: system-ui, "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; height: 100%; scrollbar-gutter: stable; }
html { overflow: hidden; }
body {
background: radial-gradient(140% 120% at 15% -10%, #1c2547 0%, #0e1226 50%, #090c18 100%) fixed;
color: var(--text);
font: 15px/1.5 var(--sans);
height: 100vh;
overflow: hidden;
padding: 84px 22px 18px;
display: flex;
flex-direction: column;
}
button, a, .card { cursor: pointer; }
a { color: inherit; text-decoration: none; }
a:focus-visible, .card:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
/* ── Keyframes (v2) ───────────────────────────────────────────────── */
@keyframes b2-rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@keyframes b2-fill { from { width: 0; } }
@keyframes b2-blink { 50% { opacity: .3; } }
@keyframes b2-draw { to { stroke-dashoffset: 0; } }
/* ── Layout ───────────────────────────────────────────────────────── */
.b2-body {
display: grid; grid-template-columns: minmax(0, 1fr) minmax(290px, 330px);
gap: 16px; min-height: 0; flex: 1 1 auto;
}
.b2-main { min-width: 0; display: flex; flex-direction: column; gap: 14px; min-height: 0; height: 100%; }
.b2-glass {
background: var(--b2-surface);
border: 1px solid var(--b2-border);
border-radius: 14px;
}
/* ── KPI row ──────────────────────────────────────────────────────── */
.b2-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; flex: 0 0 auto; }
.b2-kpi { padding: 12px 15px; position: relative; overflow: hidden; animation: b2-rise .6s both; }
.b2-kpi:nth-child(2) { animation-delay: .07s; }
.b2-kpi:nth-child(3) { animation-delay: .14s; }
.b2-kpi:nth-child(4) { animation-delay: .21s; }
.b2-lbl { color: var(--b2-muted); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; }
.b2-val { font-size: 26px; font-weight: 700; margin-top: 3px; font-family: var(--mono); }
.b2-val small { font-size: 13px; color: var(--b2-muted); font-weight: 500; }
.b2-miniarea { margin-top: 8px; }
.b2-spark { stroke-dasharray: 400; stroke-dashoffset: 400; animation: b2-draw 1.6s .3s forwards ease-out; }
.b2-chips { display: flex; gap: 6px; margin-top: 10px; align-items: center; flex-wrap: wrap; min-height: 24px; }
.b2-achip {
display: inline-flex; align-items: center; gap: 5px;
font: 11px/1 var(--mono); padding: 3px 9px 3px 4px;
border-radius: 999px; border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.05); color: var(--b2-text);
}
.b2-achip i {
width: 16px; height: 16px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-style: normal; font-size: 9px; font-weight: 800; color: #0e1226;
}
.b2-achip.b2-more { padding: 3px 9px; color: var(--b2-muted); }
.b2-cdot { width: 6px; height: 6px; border-radius: 50%; background: var(--b2-green); animation: b2-blink 1.6s infinite; }
.b2-cap-r { text-align: right; font-size: 11px; color: var(--b2-muted); margin: 8px 0 3px; font-family: var(--mono); }
.b2-pbar { height: 5px; border-radius: 999px; background: rgba(255,255,255,.08); overflow: hidden; }
.b2-pbar i { display: block; height: 100%; border-radius: 999px;
background: linear-gradient(90deg, #34d399, #10b981);
animation: b2-fill 1.3s .4s both; transition: width 600ms ease; }
/* value-change pulse (ported behaviour, new look) */
.b2-kpi.metric-flash { animation: metricFlash 900ms ease; }
@keyframes metricFlash {
0% { background: rgba(56,189,248,.14); box-shadow: 0 0 0 2px rgba(56,189,248,.35); }
100% { background: var(--b2-surface); box-shadow: 0 0 0 0 rgba(56,189,248,0); }
}
/* ── Board columns & task cards (ported v1, glass look) ───────────── */
.board {
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px; align-items: stretch; min-width: 0; min-height: 0; flex: 1 1 auto;
}
.column {
min-width: 0; background: var(--b2-surface); border: 1px solid var(--b2-border);
border-radius: 14px; padding: 12px; min-height: 0;
display: flex; flex-direction: column;
}
.col-head { display: flex; align-items: center; gap: 8px; padding: 0 2px 11px; }
.col-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.col-label { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .12em; color: var(--b2-muted); }
.col-count { margin-left: auto; min-width: 26px; text-align: center; border-radius: 999px;
padding: 1px 9px; background: rgba(255,255,255,.08); color: var(--b2-muted); font: 12px/1.5 var(--mono); }
.column[data-column="open"] .col-dot { background: var(--open); }
.column[data-column="in_progress"] .col-dot { background: var(--in_progress); }
.column[data-column="review"] .col-dot { background: var(--review); }
.column[data-column="done"] .col-dot { background: var(--done); }
.column[data-column="cancelled"] .col-dot { background: var(--cancelled); }
.cards {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
grid-auto-rows: max-content; /* auto-Tracks fielen auf min-height zurück → Overlap */
gap: 10px; min-width: 0; min-height: 0; overflow-y: auto;
overscroll-behavior: contain; padding: 2px 4px 2px 2px;
align-items: start; scrollbar-gutter: stable;
}
.card {
min-width: 0; max-width: 100%; overflow: hidden;
background: var(--b2-raised); border: 1px solid var(--b2-border);
border-left: 3px solid var(--accent); border-radius: 11px;
padding: 10px 12px; min-height: 142px;
display: flex; flex-direction: column; user-select: none;
transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease, min-height 200ms ease;
}
.card:hover { border-color: rgba(56,189,248,.4); background: rgba(255,255,255,.075); transform: translateY(-1px); box-shadow: 0 12px 28px rgba(2,6,18,.3); }
.column[data-column="open"] .card { border-left-color: var(--open); }
.column[data-column="in_progress"] .card { border-left-color: var(--in_progress); }
.column[data-column="review"] .card { border-left-color: var(--review); }
.column[data-column="done"] .card { border-left-color: var(--done); }
.column[data-column="cancelled"] .card { border-left-color: var(--cancelled); }
.card.b2-working { border-color: rgba(56,189,248,.35); box-shadow: 0 0 22px rgba(56,189,248,.08); }
.card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 5px; }
.id { color: var(--b2-accent); font: 11px/1.3 var(--mono); white-space: nowrap; opacity: .85; }
.title { margin: 0 0 8px; font-weight: 650; font-size: 13.5px; line-height: 1.35;
overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
.meta-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: auto; }
.pill, .badge {
display: inline-flex; align-items: center; gap: 5px; min-height: 22px;
border-radius: 999px; padding: 2px 8px;
border: 1px solid rgba(148,163,184,.25); background: rgba(148,163,184,.08);
color: var(--b2-muted); font: 11px/1.2 var(--mono); white-space: nowrap;
}
.status-pill { color: var(--text); }
.status-open { border-color: rgba(139,148,158,.38); color: var(--open); }
.status-in_progress { border-color: rgba(56,189,248,.42); color: var(--in_progress); background: rgba(56,189,248,.10); }
.status-review { border-color: rgba(251,191,36,.42); color: var(--review); background: rgba(251,191,36,.10); }
.status-done { border-color: rgba(52,211,153,.40); color: var(--done); background: rgba(52,211,153,.10); }
.status-cancelled { border-color: rgba(110,118,129,.45); color: var(--cancelled); }
.project-tag { color: var(--accent); border-color: rgba(56,189,248,.30); background: rgba(56,189,248,.08); }
.timer-badge { color: var(--text); background: rgba(255,255,255,.05); }
.avatar {
display: inline-flex; align-items: center; gap: 6px; min-height: 24px; max-width: 100%;
padding: 2px 8px 2px 3px; border-radius: 999px;
border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.05);
color: var(--text); font: 11px/1.2 var(--mono);
}
.avatar-core { width: 18px; height: 18px; border-radius: 50%; display: inline-grid; place-items: center;
background: var(--agent-color); color: #0e1226; font-size: 9px; font-weight: 800; }
.avatar.architect { box-shadow: 0 0 0 2px rgba(217,119,87,.24); }
.avatar-label { overflow: hidden; text-overflow: ellipsis; max-width: 120px; }
.reviewer-wrap { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: 100%; }
.reviewer-label { color: var(--review); font: 10px/1.2 var(--mono); white-space: nowrap; }
.empty { color: var(--b2-muted); font-size: 12px; padding: 4px 2px; }
/* ── Progress ring (in-progress cards) ────────────────────────────── */
.b2-ring { display: inline-flex; align-items: center; gap: 7px; margin-top: 8px; }
.b2-ring svg { display: block; transform: rotate(-90deg); }
.b2-ring-track { fill: none; stroke: rgba(255,255,255,.09); }
.b2-ring-fill { fill: none; stroke: var(--in_progress); stroke-linecap: round;
transition: stroke-dashoffset 900ms cubic-bezier(.2,.7,.2,1), stroke 300ms ease; }
.b2-ring.b2-over .b2-ring-fill { stroke: var(--b2-amber); }
.b2-ring.b2-ring-review .b2-ring-fill { stroke: var(--review); }
.b2-ring-time { font: 11px/1 var(--mono); color: var(--b2-muted); }
.b2-ring.b2-over .b2-ring-time { color: var(--b2-amber); }
/* ── Card delete affordance ───────────────────────────────────────── */
.card-top-right { display: flex; align-items: center; gap: 6px; }
.card-del { width: 24px; height: 24px; flex: 0 0 auto; display: inline-grid; place-items: center;
border-radius: 7px; border: 1px solid transparent; background: transparent; color: var(--b2-muted);
line-height: 1; cursor: pointer; opacity: 0; transition: opacity 140ms, color 140ms, border-color 140ms, background 140ms; }
.card:hover .card-del, .card:focus-within .card-del { opacity: 1; }
.card-del:hover { color: #fca5a5; border-color: rgba(239,68,68,.5); background: rgba(239,68,68,.12); }
/* ── In-card live console (ported) ────────────────────────────────── */
.card-console-wrap { margin-top: 9px; }
.card:has(.card-console:not([hidden])) { min-height: 230px; }
.card-console-toggle { display: inline-flex; align-items: center; gap: 6px;
background: transparent; border: 1px solid var(--b2-border); color: var(--b2-muted);
font: 700 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em;
padding: 4px 9px; border-radius: 6px; cursor: pointer; transition: color 140ms, border-color 140ms; }
.card-console-toggle:hover { color: var(--text); border-color: var(--b2-muted); }
.card-console-toggle[aria-expanded="true"] { color: var(--in_progress); border-color: rgba(56,189,248,.4); }
.card-console-toggle .cc-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--in_progress);
box-shadow: 0 0 0 3px rgba(56,189,248,.16); animation: ccPulse 1.6s ease-in-out infinite; }
.card-console-toggle .cc-chevron { font-size: 9px; opacity: .75; }
@keyframes ccPulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
.card-console { margin-top: 7px; }
.card-console[hidden] { display: none; }
.card-console-body { max-height: 180px; overflow-y: auto; background: rgba(0,0,0,.28);
border: 1px solid var(--b2-border); border-radius: 8px; padding: 8px 10px;
font: 11px/1.55 var(--mono); color: #cbd5e1; }
.cc-line { display: flex; gap: 8px; white-space: pre-wrap; overflow-wrap: anywhere; padding: 1px 0; }
.cc-line .cc-ts { color: var(--b2-muted); opacity: .8; flex: 0 0 auto; }
.cc-line .cc-agent { color: var(--accent); font-weight: 600; flex: 0 0 auto; }
.cc-line.level-error .cc-text { color: #fca5a5; }
.cc-line.level-warn .cc-text { color: #fcd34d; }
.cc-line.level-bridge .cc-text { color: #c4b5fd; }
.cc-empty { color: var(--b2-muted); font-style: italic; }
/* ── Drag & drop (ported) ─────────────────────────────────────────── */
.card { touch-action: none; }
.card.dragging { opacity: .5; cursor: grabbing; transform: scale(.98) rotate(-1deg); }
.card.flash { animation: cardFlash 900ms ease; }
@keyframes cardFlash {
0% { border-color: var(--green); box-shadow: 0 0 0 2px rgba(52,211,153,.4); }
100% { border-color: var(--b2-border); box-shadow: none; }
}
/* Enter animation only on genuinely new card nodes — NOT every rerender. */
.card-new { animation: cardEnter 240ms cubic-bezier(.2,.7,.2,1); }
@keyframes cardEnter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.column.drop-active { border-color: var(--accent); background: rgba(56,189,248,.07); box-shadow: 0 0 0 1px rgba(56,189,248,.4) inset; }
.column.drop-active .col-head { color: var(--accent); }
.column .cards.drop-hover { outline: 1px dashed rgba(56,189,248,.5); outline-offset: 3px; border-radius: 6px; }
.card.drop-active { border-color: var(--green); background: rgba(52,211,153,.09); box-shadow: 0 0 0 2px rgba(52,211,153,.45); transform: translateY(-1px); }
/* ── Sidebar ──────────────────────────────────────────────────────── */
.b2-side { display: flex; flex-direction: column; gap: 12px; min-width: 0;
position: sticky; top: 84px; max-height: calc(100vh - 102px); overflow-y: auto; }
.b2-panel { padding: 13px 15px; }
.b2-panel h6 { margin: 0; font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--b2-muted); }
.b2-subhead { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.b2-subhead .seg { margin-left: auto; }
#budgetRows { margin-top: 10px; }
.b2-chart-tip { color: var(--b2-muted); font-size: 11px; text-align: right; margin-top: 5px; font-family: var(--mono); }
/* Budget internals (ported v1: seg, reset, donuts, legend, agent bars) */
.budget-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
.budget-head .est-note { color: var(--b2-muted); font-size: 11px; }
.budget-head .total { margin-left: auto; font: 700 14px/1 var(--mono); color: var(--text); }
.budget-head .total small { color: var(--b2-muted); font-weight: 400; }
.seg { display: inline-flex; align-items: center; gap: 2px; padding: 3px;
border: 1px solid var(--b2-border); border-radius: 8px; background: rgba(0,0,0,.2); }
.seg button, .reset-btn { min-height: 26px; border: 0; border-radius: 6px; color: var(--b2-muted);
background: transparent; font: 700 11px/1 var(--mono); padding: 0 9px; cursor: pointer;
transition: color 160ms ease, background 160ms ease; }
.seg button.active { color: var(--text); background: rgba(255,255,255,.1); }
.reset-btn { border: 1px solid rgba(148,163,184,.22); }
.reset-btn:hover, .seg button:hover { color: var(--text); background: rgba(148,163,184,.12); }
.donut-grid { display: grid; grid-template-columns: 1fr; gap: 12px; }
.donut-card { min-width: 0; padding: 10px; border: 1px solid var(--b2-border); border-radius: 10px; background: rgba(0,0,0,.18); }
.donut-title { margin: 0 0 6px; color: var(--b2-muted); font: 700 11px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .05em; }
.donut-tabs { display: flex; justify-content: flex-end; gap: 3px; margin: -2px 0 4px; }
.donut-tabs button { min-height: 26px; border: 1px solid var(--b2-border); border-radius: 6px;
background: transparent; color: var(--b2-muted); font: 700 10.5px/1 var(--mono); padding: 0 8px; }
.donut-tabs button.active { background: rgba(255,255,255,.1); color: var(--text); }
.half-donut { position: relative; width: min(220px, 100%); aspect-ratio: 220 / 132; display: grid; place-items: center; margin: 0 auto; }
.half-donut svg { grid-area: 1 / 1; width: 100%; height: 100%; overflow: visible; }
.donut-segment { animation: donutDraw 1100ms cubic-bezier(.2,.7,.2,1) both; }
@keyframes donutDraw { from { stroke-dashoffset: 100; } to { stroke-dashoffset: 0; } }
.donut-value { fill: var(--text); font: 800 28px var(--mono); }
.donut-label { fill: var(--b2-muted); font: 10px var(--mono); }
.legend { display: grid; gap: 6px; margin-top: 6px; }
.legend-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 7px; font: 11px/1.25 var(--mono); color: var(--b2-muted); }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 16%, transparent); }
.legend-name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-bars { margin-top: 12px; }
.agent-bars h3 { margin: 0 0 8px; font: 700 11px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--b2-muted); }
.agent-bar-row { display: grid; grid-template-columns: minmax(96px, 1fr) 1.35fr; gap: 10px; align-items: center;
padding: 8px 0; border-top: 1px solid var(--b2-border); }
.agent-bar-row:first-of-type { border-top: 0; }
.budget-agent { display: flex; align-items: center; gap: 8px; min-width: 0; }
.budget-agent .ba-core { width: 24px; height: 24px; border-radius: 50%; display: inline-grid; place-items: center;
background: var(--agent-color); color: #0e1226; font: 800 9px/1 var(--mono); flex: 0 0 auto; }
.budget-agent .ba-name { display: block; font-weight: 650; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.budget-agent .ba-model { display: block; color: var(--b2-muted); font: 10px/1.2 var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.agent-bar-cell { min-width: 0; }
.agent-bar-track { height: 7px; border-radius: 999px; background: rgba(255,255,255,.07); overflow: hidden; }
.agent-bar-track > span { display: block; height: 100%;
background: linear-gradient(90deg, var(--bar-color), color-mix(in srgb, var(--bar-color) 62%, #fff));
width: 0; transition: width 900ms cubic-bezier(.2,.7,.2,1); }
.agent-bar-meta { display: flex; justify-content: space-between; gap: 8px; margin-top: 4px; color: var(--b2-muted); font: 10.5px/1.25 var(--mono); }
.agent-bar-meta .tok { color: var(--text); font-weight: 650; }
.budget-empty { color: var(--b2-muted); font-size: 12px; padding: 6px 0; }
/* ── Live feed ────────────────────────────────────────────────────── */
.b2-feed { margin-top: 8px; font-size: 12px; color: #aab6d4; font-family: var(--mono); }
.b2-feed > div { padding: 4px 0; border-bottom: 1px dashed rgba(255,255,255,.06); display: flex; gap: 7px; align-items: baseline; }
.b2-feed > div:last-child { border-bottom: 0; }
.b2-feed .pulse { color: var(--b2-green); animation: b2-blink 1.6s infinite; }
.b2-feed .f-time { color: var(--b2-dim); font-size: 10px; margin-left: auto; flex: 0 0 auto; }
/* ── Modals (ported v1) ───────────────────────────────────────────── */
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start;
justify-content: center; padding: 12vh 16px 16px; background: rgba(2,6,18,.62);
backdrop-filter: blur(3px); animation: modalFade 140ms ease; }
.modal-backdrop[hidden] { display: none; }
@keyframes modalFade { from { opacity: 0; } to { opacity: 1; } }
.modal { width: min(520px, 100%); background: #10142a; border: 1px solid var(--b2-border);
border-radius: 14px; box-shadow: 0 24px 64px rgba(0,0,0,.5); animation: modalRise 180ms cubic-bezier(.2,.7,.2,1); }
@keyframes modalRise { from { transform: translateY(12px); opacity: .4; } to { transform: none; opacity: 1; } }
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 8px; }
.modal-head h2 { margin: 0; font-size: 16px; font-weight: 700; }
.modal-x { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--b2-border);
background: var(--b2-raised); color: var(--b2-muted); font-size: 20px; line-height: 1; cursor: pointer;
transition: color 140ms, border-color 140ms; }
.modal-x:hover { color: var(--text); border-color: var(--b2-muted); }
#tmForm { padding: 6px 18px 18px; display: flex; flex-direction: column; gap: 12px; }
.modal-field { display: flex; flex-direction: column; gap: 5px; }
.modal-field-inline { flex-direction: row; align-items: center; gap: 12px; }
.modal-field-inline .modal-label { margin: 0; }
.modal-label { color: var(--b2-muted); font: 600 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em; }
.modal-field input, .modal-field textarea, .modal-field select {
background: rgba(0,0,0,.22); border: 1px solid var(--b2-border); border-radius: 8px;
color: var(--text); font: 14px/1.4 var(--sans); padding: 9px 11px; }
.modal-field textarea { resize: vertical; min-height: 84px; }
.modal-field input:focus, .modal-field textarea:focus, .modal-field select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.modal-note { margin: 0; color: var(--b2-muted); font-size: 11.5px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }
.modal-cancel, .modal-create { min-height: 38px; padding: 0 16px; border-radius: 8px;
font: 600 13px/1 var(--sans); cursor: pointer; transition: background 160ms ease, transform 120ms ease, border-color 160ms ease; }
.modal-cancel { border: 1px solid var(--b2-border); background: var(--b2-raised); color: var(--b2-muted); }
.modal-cancel:hover { color: var(--text); border-color: var(--b2-muted); }
.modal-create { border: 1px solid var(--green); background: rgba(52,211,153,.16); color: #d1fadf; }
.modal-create:hover { background: rgba(52,211,153,.26); }
.modal-create:active, .modal-cancel:active { transform: translateY(1px); }
.confirm-modal { width: min(420px, 100%); }
.confirm-body { padding: 2px 18px 2px; }
.confirm-body p { margin: 0; color: var(--b2-muted); font-size: 13.5px; line-height: 1.5; }
.confirm-body b { color: var(--text); }
.confirm-modal .modal-actions { padding: 14px 18px 18px; margin-top: 0; }
.modal-danger { min-height: 38px; padding: 0 16px; border-radius: 8px;
border: 1px solid var(--danger); background: rgba(248,81,73,.16); color: #ffd7d3;
font: 600 13px/1 var(--sans); cursor: pointer; transition: background 160ms ease, transform 120ms ease; }
.modal-danger:hover { background: rgba(248,81,73,.26); }
.modal-danger:active { transform: translateY(1px); }
.detail-modal { width: min(760px, 100%); max-height: min(78vh, 760px); display: flex; flex-direction: column; }
.detail-body { min-height: 0; overflow-y: auto; padding: 4px 18px 18px; display: grid; gap: 12px; }
.detail-topline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.detail-title { margin: 0; font-size: 19px; line-height: 1.25; overflow-wrap: anywhere; }
.detail-section { border-top: 1px solid var(--b2-border); padding-top: 12px; display: grid; gap: 8px; }
.detail-section h3 { margin: 0; color: var(--b2-muted); font: 700 11px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .05em; }
.detail-description { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text); font: 12.5px/1.55 var(--mono); }
.detail-activity { display: grid; gap: 0; }
.detail-activity-row { display: grid; grid-template-columns: 78px 82px minmax(0, 1fr) auto; gap: 8px; padding: 8px 0; border-top: 1px solid rgba(255,255,255,.06); align-items: start; }
.detail-activity-row:first-child { border-top: 0; padding-top: 0; }
.detail-when, .detail-actor { color: var(--b2-muted); font: 11px/1.45 var(--mono); }
.detail-kind { color: var(--accent); font: 11px/1.45 var(--mono); }
.detail-summary { min-width: 0; overflow-wrap: anywhere; }
.detail-error { color: #fca5a5; }
/* ── Toasts (ported v1) ───────────────────────────────────────────── */
.toasts { position: fixed; right: 18px; bottom: 18px; z-index: 50; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.toast { display: flex; align-items: center; gap: 8px; background: #151a33; border: 1px solid var(--b2-border);
border-left: 3px solid var(--green); border-radius: 10px; padding: 9px 13px; color: var(--text);
font: 13px/1.3 var(--sans); box-shadow: 0 8px 28px rgba(0,0,0,.42);
animation: toastIn 220ms cubic-bezier(.2,.7,.2,1); max-width: 340px; }
.toast.out { animation: toastOut 240ms ease forwards; }
.toast .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(52,211,153,.18); flex: 0 0 auto; }
.toast.err { border-left-color: var(--danger); }
.toast.err .dot { background: var(--danger); box-shadow: 0 0 0 3px rgba(248,81,73,.18); }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px) scale(.98); } to { opacity: 1; transform: none; } }
@keyframes toastOut { to { opacity: 0; transform: translateY(6px); } }
/* ── Splash ───────────────────────────────────────────────────────── */
.b2-splash { position: fixed; inset: 0; z-index: 999; display: flex; align-items: center; justify-content: center;
background: radial-gradient(140% 120% at 15% -10%, #1c2547 0%, #0e1226 50%, #090c18 100%);
transition: opacity .4s ease; }
.b2-splash-hide { opacity: 0; pointer-events: none; }
.b2-splash-inner { text-align: center; animation: b2-rise .5s both; }
.b2-splash-logo { animation: b2-ping 1.6s ease-in-out infinite; border-radius: 18px; }
.b2-splash-word { margin-top: 14px; font-size: 22px; font-weight: 700; letter-spacing: .02em; color: #eef1f8; }
/* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 1120px) {
.b2-body { grid-template-columns: 1fr; }
.b2-side { position: static; max-height: none; }
}
@media (max-width: 860px) {
body { padding-left: 14px; padding-right: 14px; }
.app-header { padding-left: 14px; padding-right: 14px; }
.b2-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.board { grid-template-columns: repeat(2, 1fr); }
.app-header .b2-proj { max-width: 46vw; }
}
@media (max-width: 560px) {
.b2-kpis { grid-template-columns: 1fr; }
.board { grid-template-columns: 1fr; }
.detail-activity-row { grid-template-columns: 1fr; gap: 3px; }
.agent-bar-row { grid-template-columns: 1fr; gap: 6px; }
}
@media (prefers-reduced-motion: reduce) {
.b2, .b2 *, body, body * { animation: none !important; transition: none !important; }
}
`;
}

View File

@ -1,335 +0,0 @@
/**
* Verbatim port of the v1 budget-panel client JS (old src/server/board.ts,
* lines 14661750). 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; });
}
// The donut card hosts three tabs: Token | Kosten | Verlauf (throughput chart).
function donutTabsHtml() {
return '<div class="donut-tabs">' +
'<button type="button" data-donut-tab="tokens" class="' + (donutMetric === 'tokens' ? 'active' : '') + '">Token</button>' +
'<button type="button" data-donut-tab="cost" class="' + (donutMetric === 'cost' ? 'active' : '') + '">Kosten</button>' +
'<button type="button" data-donut-tab="verlauf" class="' + (donutMetric === 'verlauf' ? 'active' : '') + '">Verlauf</button>' +
'</div>';
}
function donutCardShell(title, bodyHtml) {
return '<div class="donut-card"><h3 class="donut-title">' + esc(title) + '</h3>' + donutTabsHtml() + bodyHtml + '</div>';
}
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 = '<path d="' + donutArc(cx, cy, r, 180, 360) + '" stroke="rgba(255,255,255,.09)" stroke-width="' + stroke + '" fill="none" stroke-linecap="round" />';
rows.forEach(function(row, idx) {
var start = angle;
var end = angle + 180 * ((row.value || 0) / safeTotal);
angle = end;
if (end <= start + 0.4) return;
paths += '<path class="donut-segment" d="' + donutArc(cx, cy, r, start, end) + '" stroke="' + esc(row.color) + '" stroke-width="' + stroke + '" fill="none" stroke-linecap="round" pathLength="100" stroke-dasharray="100" stroke-dashoffset="100" style="animation-delay:' + (idx * 80) + 'ms" />';
});
if (!rows.length) paths += '<circle cx="' + cx + '" cy="' + (cy - r + stroke / 2) + '" r="3" fill="#94A3B8" />';
var legend = rows.length ? rows.map(function(row) {
var pct = total > 0 ? Math.round((row.value / total) * 100) : 0;
return '<div class="legend-row" style="color:' + esc(row.color) + '">' +
'<span class="legend-dot"></span><span class="legend-name">' + esc(row.name) + '</span>' +
'<span>' + esc(formatValue(row.value)) + ' · ' + pct + '%</span></div>';
}).join('') : '<div class="empty">no provider data</div>';
return '<div class="donut-card">' +
'<h3 class="donut-title">' + esc(title) + '</h3>' +
donutTabsHtml() +
'<div class="half-donut">' +
'<svg viewBox="0 0 220 132" role="img" aria-label="' + esc(title) + '">' + paths +
'<text class="donut-value" x="110" y="102" text-anchor="middle" dominant-baseline="central" data-count-to="' + esc(centerValue) + '" data-count-format="' + (formatValue === fmtEur ? 'eur' : 'tokens') + '">0</text>' +
'<text class="donut-label" x="110" y="120" text-anchor="middle" dominant-baseline="central">' + esc(centerLabel) + '</text>' +
'</svg>' +
'</div>' +
'<div class="legend">' + legend + '</div>' +
'</div>';
}
function animateCounts(root) {
var nodes = (root || document).querySelectorAll('[data-count-to]');
nodes.forEach(function(node) {
var target = Number(node.getAttribute('data-count-to')) || 0;
var format = node.getAttribute('data-count-format');
var start = performance.now();
function step(now) {
var p = Math.min(1, (now - start) / 900);
var eased = 1 - Math.pow(1 - p, 3);
var val = target * eased;
node.textContent = format === 'eur' ? fmtEur(val) : fmtTokens(val);
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
});
}
function signatureFor(rows) {
return rows.map(function(r) { return r.kind + ':' + Math.round((Number(r.value) || 0) * 100); }).join('|');
}
function shouldRenderDonut(nextSig, force) {
if (force) return true;
var now = Date.now();
if (!lastDonutSignature || now - lastDonutAt > DONUT_REFRESH_MS) return true;
if (nextSig === lastDonutSignature) return false;
var prev = {};
lastDonutSignature.split('|').forEach(function(part) {
if (!part) return;
var bits = part.split(':');
prev[bits[0]] = Number(bits[1]) || 0;
});
var changed = false;
nextSig.split('|').forEach(function(part) {
if (!part) return;
var bits = part.split(':');
var oldVal = prev[bits[0]] || 0;
var newVal = Number(bits[1]) || 0;
if (Math.abs(newVal - oldVal) / Math.max(1, oldVal) > 0.10) changed = true;
});
return changed;
}
function renderDonut(active, force) {
var wrap = document.getElementById('donutWrap');
if (!wrap) return;
// Verlauf tab: throughput area chart instead of the donut. Static
// signature — the chart content itself updates via __b2RenderThroughput
// on task refreshes, so the 3s budget poll must not rebuild the card.
if (donutMetric === 'verlauf') {
if (lastDonutSignature === 'verlauf' && !force) return;
lastDonutSignature = 'verlauf';
lastDonutAt = Date.now();
wrap.innerHTML = donutCardShell('Verlauf · erledigte Tasks',
'<div id="b2Throughput"></div><div class="b2-chart-tip" id="b2ThroughputTip"></div>');
if (window.__b2RenderThroughput) {
window.__b2RenderThroughput(typeof lastTasks !== 'undefined' ? lastTasks : []);
}
return;
}
var field = donutMetric === 'cost' ? 'costEur' : 'tokens';
var companies = aggregateByCompany(active, field);
var sig = donutMetric + ':' + signatureFor(companies);
if (!shouldRenderDonut(sig, force)) return;
lastDonutSignature = sig;
lastDonutAt = Date.now();
var center = active.reduce(function(s, a) { return s + (Number(a[field]) || 0); }, 0);
wrap.innerHTML = halfDonut(donutMetric === 'cost' ? 'Cost by Company' : 'Tokens by Company', companies, center, donutMetric === 'cost' ? 'EUR' : 'tokens', donutMetric === 'cost' ? fmtEur : fmtTokens);
animateCounts(wrap);
}
function agentBarHtml(a, mx) {
var spec = agentSpec(a.name);
var est = a.estimated ? '<span class="est">~ estimated</span>' : 'real';
var pct = mx > 0 ? Math.round(((a.tokens || 0) / mx) * 1000) / 10 : 0;
var company = companySpec(a.kind);
return '<div class="agent-bar-row" data-agent-row="' + esc(a.name) + '">' +
'<div class="budget-agent"><span class="ba-core" style="background:' + esc(spec.color) + '">' + esc(spec.initials) + '</span>' +
'<span style="min-width:0"><span class="ba-name">' + esc(a.name) + '</span>' +
'<span class="ba-model">' + esc(company.name + (a.model ? ' · ' + a.model : '')) + '</span></span></div>' +
'<div class="agent-bar-cell"><div class="agent-bar-track" style="--bar-color:' + esc(company.color) + '"><span style="width:' + Math.min(100, pct) + '%"></span></div>' +
'<div class="agent-bar-meta"><span class="tok">' + fmtTokens(a.tokens) + ' tok</span><span>' + est + ' · ' + fmtEur(a.costEur) + '</span></div></div>' +
'</div>';
}
function renderAgentBars(active) {
var host = document.getElementById('agentBars');
if (!host) return;
var before = {};
host.querySelectorAll('[data-agent-row]').forEach(function(el) {
before[el.getAttribute('data-agent-row')] = el.getBoundingClientRect();
});
var mx = maxTok(active) || 1;
var sorted = active.slice().sort(function(a, b) { return (b.tokens || 0) - (a.tokens || 0) || a.name.localeCompare(b.name); });
host.innerHTML = '<h3>Token-Verbrauch pro Agent</h3>' + sorted.map(function(a) { return agentBarHtml(a, mx); }).join('');
host.querySelectorAll('[data-agent-row]').forEach(function(el) {
var key = el.getAttribute('data-agent-row');
var old = before[key];
if (!old) return;
var now = el.getBoundingClientRect();
var dy = old.top - now.top;
if (!dy) return;
el.style.transform = 'translateY(' + dy + 'px)';
requestAnimationFrame(function() { el.style.transform = ''; });
});
}
function updateBudgetButtons() {
document.querySelectorAll('[data-budget-mode]').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-budget-mode') === budgetMode);
});
document.querySelectorAll('[data-donut-tab]').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-donut-tab') === donutMetric);
});
}
function renderBudget(rep, opts) {
opts = opts || {};
BUDGET = rep;
var rows = document.getElementById('budgetRows');
var total = document.getElementById('budgetTotal');
if (!rows || !total) return;
var scoped = applyBudgetMode(rep);
var active = scoped.agents.filter(function(a) { return a.kind && a.tokens > 0; });
updateBudgetButtons();
// The card skeleton (and with it the Token/Kosten/Verlauf tabs) always
// exists — even in the empty state the Verlauf tab must stay reachable.
if (!document.getElementById('donutWrap') || !document.getElementById('agentBars')) {
rows.innerHTML = '<div class="donut-grid"><div id="donutWrap"></div></div><div class="agent-bars" id="agentBars"></div>';
}
var bars = document.getElementById('agentBars');
if (!active.length) {
if (donutMetric === 'verlauf') {
renderDonut(active, opts.forceDonut);
} else {
lastDonutSignature = '';
document.getElementById('donutWrap').innerHTML = donutCardShell('Token Insights',
'<div class="budget-empty">no real agent token data in this ' + (budgetMode === 'session' ? 'session' : 'total range') + '</div>');
}
if (bars) bars.innerHTML = '';
total.textContent = '';
return;
}
renderDonut(active, opts.forceDonut);
// Agent bars belong to the token insights; hidden on the Verlauf tab.
if (donutMetric === 'verlauf') { if (bars) bars.innerHTML = ''; }
else renderAgentBars(active);
total.innerHTML = (scoped.totals.estimated ? '~' : '') + fmtTokens(scoped.totals.tokens) +
' tok <small>\\u00b7 \\u2248 ' + fmtEur(scoped.totals.costEur) + '</small>';
}
async function refreshBudget() {
try { renderBudget(await getJSON('/budget')); } catch (_) {}
}
document.addEventListener('click', function(e) {
var modeBtn = e.target.closest && e.target.closest('[data-budget-mode]');
if (modeBtn) {
budgetMode = modeBtn.getAttribute('data-budget-mode') || 'session';
localStorage.setItem('agenthub-budget-mode', budgetMode);
lastDonutSignature = '';
if (BUDGET) renderBudget(BUDGET, { forceDonut: true });
return;
}
var tabBtn = e.target.closest && e.target.closest('[data-donut-tab]');
if (tabBtn) {
donutMetric = tabBtn.getAttribute('data-donut-tab') || 'tokens';
localStorage.setItem('agenthub-donut-metric', donutMetric);
if (BUDGET) renderBudget(BUDGET, { forceDonut: true });
return;
}
var reset = e.target.closest && e.target.closest('#budgetReset');
if (reset) {
if (BUDGET) {
writeBaseline(BUDGET);
budgetMode = 'session';
localStorage.setItem('agenthub-budget-mode', budgetMode);
lastDonutSignature = '';
renderBudget(BUDGET, { forceDonut: true });
toast('Token session reset');
}
}
});
`;

View File

@ -1,135 +0,0 @@
/**
* Pure, dependency-free helpers for the board v2 UI.
* These run BOTH in vitest (server-side) and in the browser board/index.ts
* injects them into the inline <script> via Function.prototype.toString().
* Therefore: no imports, no closures over module state, ES2019 syntax only.
*/
export interface KpiTask {
id: string;
status?: string;
assignedTo?: string;
reviewer?: string;
createdAt?: string;
updatedAt?: string;
claimedAt?: string;
}
export interface AgentChip {
name: string;
minutes: number | null;
}
const DAY_MS = 24 * 3600 * 1000;
/** Cap a chip list at `max` visible entries, reporting how many were hidden. */
export function capChips<T>(chips: T[], max: number): { visible: T[]; hidden: number } {
const visible = chips.slice(0, Math.max(0, max));
return { visible, hidden: chips.length - visible.length };
}
/** Done-card model: share of non-cancelled tasks done + done in the last 7 days. */
export function doneStats(
tasks: KpiTask[],
now?: number,
): { done: number; total: number; pct: number; doneThisWeek: number } {
const t0 = now ?? Date.now();
const relevant = tasks.filter((t) => t.status !== 'cancelled');
const doneTasks = relevant.filter((t) => t.status === 'done');
const weekAgo = t0 - 7 * DAY_MS;
const doneThisWeek = doneTasks.filter(
(t) => t.updatedAt && new Date(t.updatedAt).getTime() >= weekAgo,
).length;
const pct = relevant.length === 0 ? 0 : Math.round((doneTasks.length / relevant.length) * 100);
return { done: doneTasks.length, total: relevant.length, pct, doneThisWeek };
}
/** Local midnight for a timestamp (exported: co-injected into the browser page). */
export function dayStart(ts: number): number {
const d = new Date(ts);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
/**
* Backlog (open) count for each of the last `days` days, oldest first.
* Approximation: a task counts as backlog on day D when it existed by end of D
* and was not yet moved out of 'open' (non-open tasks use updatedAt as the
* transition timestamp; still-open tasks are backlog on every day since creation).
*/
export function backlogSeries(tasks: KpiTask[], days: number, now?: number): number[] {
const today = dayStart(now ?? Date.now());
const out: number[] = [];
for (let i = days - 1; i >= 0; i--) {
const endOfDay = today - i * DAY_MS + DAY_MS - 1;
let count = 0;
for (const t of tasks) {
const created = t.createdAt ? new Date(t.createdAt).getTime() : 0;
if (created > endOfDay) continue;
if (t.status === 'open') {
count++;
} else if (t.status !== 'cancelled') {
const left = t.updatedAt ? new Date(t.updatedAt).getTime() : created;
if (left > endOfDay) count++; // was still open on that day
}
}
out.push(count);
}
return out;
}
/** Done-per-day counts for the last `days` days, oldest first. */
export function throughputSeries(tasks: KpiTask[], days: number, now?: number): number[] {
const today = dayStart(now ?? Date.now());
const out: number[] = [];
for (let i = days - 1; i >= 0; i--) {
const start = today - i * DAY_MS;
const end = start + DAY_MS - 1;
out.push(
tasks.filter(
(t) =>
t.status === 'done' &&
t.updatedAt &&
new Date(t.updatedAt).getTime() >= start &&
new Date(t.updatedAt).getTime() <= end,
).length,
);
}
return out;
}
/** Build SVG line + area path data for a value series. Values are top-anchored (max = y 0). */
export function areaPath(
values: number[],
w: number,
h: number,
): { line: string; area: string } {
if (values.length === 0) return { line: '', area: '' };
const max = Math.max(...values);
const min = Math.min(...values);
const span = max - min;
const step = values.length > 1 ? w / (values.length - 1) : 0;
const pts = values.map((v, i) => {
const x = Math.round(i * step * 100) / 100;
const y = span === 0 ? h / 2 : Math.round(((max - v) / span) * h * 100) / 100;
return `${x},${y}`;
});
const line = `M${pts.join(' L')}`;
return { line, area: `${line} L${w},${h} L0,${h} Z` };
}
/** Chip model for one status lane (in_progress → assignedTo, review → reviewer). */
export function laneChips(tasks: KpiTask[], status: string, now?: number): AgentChip[] {
const t0 = now ?? Date.now();
return tasks
.filter((t) => t.status === status)
.map((t) => {
const name = status === 'review' ? t.reviewer ?? t.assignedTo : t.assignedTo;
const since = t.claimedAt ?? t.updatedAt;
return {
name: name ?? '?',
minutes: since ? Math.max(0, Math.round((t0 - new Date(since).getTime()) / 60000)) : null,
};
})
.filter((c) => c.name !== '?');
}

View File

@ -1,6 +1,6 @@
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'message' | 'ask' | 'agent'; export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'message' | 'agent';
export type AgentHubEventAction = 'created' | 'updated' | 'deleted' | 'joined' | 'left'; export type AgentHubEventAction = 'created' | 'updated' | 'deleted' | 'joined' | 'left';
export interface AgentHubEvent { export interface AgentHubEvent {
@ -12,7 +12,6 @@ export interface AgentHubEvent {
status?: string; status?: string;
role?: string; role?: string;
assignedTo?: string; assignedTo?: string;
claimedBy?: string;
reviewer?: string; reviewer?: string;
} }

View File

@ -26,7 +26,6 @@ const WATCHED: { dir: EntityType; type: AgentHubEventType }[] = [
{ dir: 'decisions', type: 'decision' }, { dir: 'decisions', type: 'decision' },
{ dir: 'memory', type: 'memory' }, { dir: 'memory', type: 'memory' },
{ dir: 'messages', type: 'message' }, { dir: 'messages', type: 'message' },
{ dir: 'asks', type: 'ask' },
]; ];
// fs.watch can fire several events (rename + change) for a single write, and a // fs.watch can fire several events (rename + change) for a single write, and a
@ -61,7 +60,7 @@ function toEvent(
case 'task': case 'task':
return { return {
stamp, stamp,
event: { type, action, id, title: str(fm.title), status: str(fm.status), role: str(fm.role), assignedTo: str(fm.assignedTo), claimedBy: str(fm.claimedBy), reviewer: str(fm.reviewer) }, event: { type, action, id, title: str(fm.title), status: str(fm.status), role: str(fm.role), assignedTo: str(fm.assignedTo), reviewer: str(fm.reviewer) },
}; };
case 'handoff': case 'handoff':
return { return {
@ -74,8 +73,6 @@ function toEvent(
return { stamp, event: { type, action, id, title: str(fm.title) } }; return { stamp, event: { type, action, id, title: str(fm.title) } };
case 'message': case 'message':
return { stamp, event: { type, action, id, title: `${str(fm.from)}${str(fm.to)}`, status: str(fm.status), assignedTo: str(fm.to) } }; return { stamp, event: { type, action, id, title: `${str(fm.from)}${str(fm.to)}`, status: str(fm.status), assignedTo: str(fm.to) } };
case 'ask':
return { stamp, event: { type, action, id, title: `${str(fm.from)}${str(fm.to)}`, status: str(fm.status), assignedTo: str(fm.to) } };
} }
} }

View File

@ -11,16 +11,6 @@ export function buildApp(cwd: string) {
return app; return app;
} }
function isTestRuntime(): boolean {
const lifecycle = process.env.npm_lifecycle_event ?? '';
return process.env.NODE_ENV === 'test'
|| process.env.VITEST === 'true'
|| process.env.VITEST_WORKER_ID !== undefined
|| process.env.VITEST_POOL_ID !== undefined
|| lifecycle === 'test'
|| lifecycle.startsWith('test:');
}
export async function startServer(cwd: string, options: { port?: number; host?: string } = {}): Promise<{ app: Fastify.FastifyInstance; url: string }> { export async function startServer(cwd: string, options: { port?: number; host?: string } = {}): Promise<{ app: Fastify.FastifyInstance; url: string }> {
const app = buildApp(cwd); const app = buildApp(cwd);
const port = options.port ?? 3377; const port = options.port ?? 3377;
@ -46,19 +36,14 @@ export async function startServer(cwd: string, options: { port?: number; host?:
const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${actualPort}`; const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${actualPort}`;
console.log(`AgentHub: server listening on ${url}`); console.log(`AgentHub: server listening on ${url}`);
// Skip LAN advertisers under test: their background timers (mDNS re-advertise const advertiseUrl = resolveAdvertiseUrl(host, actualPort);
// poll + UDP discovery broadcaster) fire during unrelated tests, flood the broadcaster = startDiscoveryBroadcaster(advertiseUrl);
// network with duplicate mDNS publishes ("Service name already in use") and
// leak non-string logs into single-instance assertions. No production change.
if (!isTestRuntime()) {
broadcaster = startDiscoveryBroadcaster(() => resolveAdvertiseUrl(host, actualPort));
// Advertise a browsable LAN hostname over mDNS (best-effort). // Advertise a browsable LAN hostname over mDNS (best-effort).
mdns = startMdnsAdvertise({ port: actualPort }); mdns = startMdnsAdvertise({ port: actualPort });
if (mdns) { if (mdns) {
const portSuffix = actualPort === 80 ? '' : `:${actualPort}`; const portSuffix = actualPort === 80 ? '' : `:${actualPort}`;
console.log(`AgentHub: reachable in a browser at http://${mdns.hostname}${portSuffix}`); console.log(`AgentHub: reachable in a browser at http://${mdns.hostname}${portSuffix}`);
}
} }
return { app, url }; return { app, url };
@ -70,7 +55,7 @@ export async function startServer(cwd: string, options: { port?: number; host?:
`Stop it, or start with a different --port.`, `Stop it, or start with a different --port.`,
); );
} else { } else {
console.error(err instanceof Error ? err.message : String(err)); console.error(err);
} }
process.exit(1); process.exit(1);
} }

View File

@ -1,5 +1,4 @@
import { Bonjour, type Service } from 'bonjour-service'; import { Bonjour, type Service } from 'bonjour-service';
import { getLanIPv4 } from '../discovery.js';
/** /**
* Advertise the hub over mDNS/Bonjour as `<name>.local`, so anyone on the LAN * Advertise the hub over mDNS/Bonjour as `<name>.local`, so anyone on the LAN
@ -16,80 +15,38 @@ export interface MdnsHandle {
stop: () => void; stop: () => void;
} }
export interface MdnsAdvertiseOptions { export function startMdnsAdvertise(opts: { port: number; name?: string }): MdnsHandle | undefined {
port: number;
name?: string;
pollMs?: number;
getIp?: () => string | undefined;
createBonjour?: () => Bonjour;
}
export function startMdnsAdvertise(opts: MdnsAdvertiseOptions): MdnsHandle | undefined {
const base = (opts.name ?? 'agenthub').toLowerCase().replace(/[^a-z0-9-]/g, '') || 'agenthub'; const base = (opts.name ?? 'agenthub').toLowerCase().replace(/[^a-z0-9-]/g, '') || 'agenthub';
const hostname = `${base}.local`; const hostname = `${base}.local`;
const pollMs = opts.pollMs ?? 15_000; try {
const readIp = opts.getIp ?? getLanIPv4; const bonjour = new Bonjour();
const createBonjour = opts.createBonjour ?? (() => new Bonjour()); const service: Service = bonjour.publish({
let currentIp = readIp();
let bonjour: Bonjour | undefined;
let service: Service | undefined;
let pollTimer: ReturnType<typeof setInterval> | undefined;
const stopCurrent = () => {
try {
service?.stop?.();
} catch {
/* ignore */
}
try {
bonjour?.destroy();
} catch {
/* ignore */
}
service = undefined;
bonjour = undefined;
};
const publish = () => {
bonjour = createBonjour();
service = bonjour.publish({
name: 'AgentHub', name: 'AgentHub',
type: 'http', type: 'http',
port: opts.port, port: opts.port,
host: hostname, host: hostname,
txt: { path: '/board', address: currentIp ?? '' }, txt: { path: '/board' },
}); });
// Swallow responder errors — advertisement is optional infrastructure. // Swallow responder errors — advertisement is optional infrastructure.
service.on('error', () => { service.on('error', () => {
/* best-effort */ /* best-effort */
}); });
};
try {
publish();
pollTimer = setInterval(() => {
const nextIp = readIp();
const changed = nextIp !== currentIp;
if (!changed && service) return;
if (changed) {
currentIp = nextIp;
stopCurrent();
}
try {
publish();
} catch {
// Keep polling; a later network state may be publishable.
}
}, pollMs);
return { return {
hostname, hostname,
stop: () => { stop: () => {
if (pollTimer) clearInterval(pollTimer); try {
stopCurrent(); service.stop?.();
} catch {
/* ignore */
}
try {
bonjour.destroy();
} catch {
/* ignore */
}
}, },
}; };
} catch { } catch {
stopCurrent();
return undefined; return undefined;
} }
} }

View File

@ -1,221 +0,0 @@
import { loadConfig } from '../core/config.js';
import { listMessages, getMessage } from '../core/services/messageService.js';
import { getRoster } from '../core/services/rosterService.js';
import { agentAvatar, designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
import type { Message } from '../core/schema.js';
function ago(iso: string): string {
const t = Date.parse(iso);
if (Number.isNaN(t)) return '';
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function snippet(text: string, max = 88): string {
const clean = (text ?? '').replace(/\s+/g, ' ').trim();
if (clean.length <= max) return clean;
return `${clean.slice(0, max - 1)}...`;
}
/** Stable, order-independent key for the conversation between two agents. */
function convoKey(a: string, b: string): string {
return [a, b].map((s) => s.toLowerCase()).sort().join('__');
}
interface Convo {
key: string;
a: string;
b: string;
messages: Message[]; // oldest → newest
lastAt: string;
unread: number; // messages addressed to the viewer not yet read/acked
}
/**
* The /messages conversation view: messaging split cleanly out of /activity.
* Two columns left the conversation list grouped by {from,to} pair, right the
* selected thread rendered as bubbles aligned by the viewer (`?as=<agent>`,
* default `architect`). Live via its own EventSource('/events'), reloading on
* message events. Reply indentation is driven by `replyTo`.
*/
export function renderMessagesHtml(cwd: string, asAgent?: string, selectedKey?: string): string {
const config = loadConfig(cwd);
const viewer = (asAgent && asAgent.trim()) || 'architect';
// Load full messages (frontmatter has replyTo + status); fall back to the
// lightweight index entry when a file can't be read.
const entries = listMessages(cwd);
const messages: Message[] = entries.map((e) => {
try {
return getMessage(cwd, e.id).message;
} catch {
return {
id: e.id,
from: e.from,
to: e.to,
text: e.text,
taskId: e.taskId,
status: (e.status as Message['status']) ?? 'unread',
createdAt: e.createdAt,
updatedAt: e.createdAt,
} as Message;
}
});
// Group into conversations.
const convos = new Map<string, Convo>();
for (const m of messages) {
const key = convoKey(m.from, m.to);
let c = convos.get(key);
if (!c) {
c = { key, a: m.from, b: m.to, messages: [], lastAt: m.createdAt, unread: 0 };
convos.set(key, c);
}
c.messages.push(m);
if (m.createdAt > c.lastAt) c.lastAt = m.createdAt;
const toViewer = m.to.toLowerCase() === viewer.toLowerCase();
if (toViewer && (m.status === 'unread' || m.status === 'delivered')) c.unread += 1;
}
const convoList = [...convos.values()].sort((x, y) => y.lastAt.localeCompare(x.lastAt));
for (const c of convoList) c.messages.sort((x, y) => x.createdAt.localeCompare(y.createdAt));
const selected = convoList.find((c) => c.key === selectedKey) ?? convoList[0];
const convoRows = convoList.length
? convoList
.map((c) => {
const last = c.messages[c.messages.length - 1];
const active = selected && c.key === selected.key;
return `<a class="convo${active ? ' active' : ''}" href="/messages?as=${encodeURIComponent(viewer)}&c=${encodeURIComponent(c.key)}">
<span class="pair">${agentAvatar(c.a, { size: 26 })}${agentAvatar(c.b, { size: 26 })}</span>
<span class="convo-main">
<span class="convo-names">${escapeHtml(c.a)} &harr; ${escapeHtml(c.b)}</span>
<span class="convo-snippet">${escapeHtml(snippet(last?.text ?? ''))}</span>
</span>
<span class="convo-meta">
${c.unread ? `<span class="badge">${c.unread}</span>` : ''}
<span class="when">${escapeHtml(ago(c.lastAt))}</span>
</span>
</a>`;
})
.join('')
: '<div class="empty">No conversations yet.</div>';
const statusLabel = (s: string) => (s === 'acked' ? 'acked' : s === 'read' ? 'read' : s === 'delivered' ? 'delivered' : 'sent');
const thread = selected
? selected.messages
.map((m) => {
const mine = m.from.toLowerCase() === viewer.toLowerCase();
const isReply = !!m.replyTo;
return `<div class="bubble-row ${mine ? 'sent' : 'recv'}${isReply ? ' reply' : ''}">
<div class="bubble">
<div class="bubble-head">${agentAvatar(m.from, { size: 20 })}<span class="from">${escapeHtml(m.from)}</span><span class="id">${escapeHtml(m.id)}</span>${m.taskId ? `<span class="task-pill">${escapeHtml(m.taskId)}</span>` : ''}</div>
<div class="bubble-text">${escapeHtml(m.text)}</div>
<div class="bubble-foot"><span class="when">${escapeHtml(ago(m.createdAt))}</span><span class="rstat" data-status="${escapeHtml(m.status)}">${escapeHtml(statusLabel(m.status))}</span></div>
</div>
</div>`;
})
.join('')
: '<div class="empty">Pick a conversation.</div>';
const roster = getRoster(cwd).map((r) => r.name);
const switchNames = ['architect', ...roster.filter((n) => n.toLowerCase() !== 'architect')];
const switcher = switchNames
.map(
(n) =>
`<a class="as-link${n.toLowerCase() === viewer.toLowerCase() ? ' active' : ''}" href="/messages?as=${encodeURIComponent(n)}${selected ? `&c=${encodeURIComponent(selected.key)}` : ''}">${escapeHtml(n)}</a>`,
)
.join('');
const headerTitle = selected ? `${escapeHtml(selected.a)} &harr; ${escapeHtml(selected.b)}` : 'Messages';
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>AgentHub Messages</title>
<style>
${designTokensCss()}
${appHeaderCss()}
body { padding: 96px 20px 32px; }
main { max-width:1120px;margin:0 auto;display:grid;grid-template-columns:minmax(280px,.9fr) minmax(0,1.6fr);gap:12px;align-items:start; }
.panel { background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px;min-width:0; }
.panel-head { display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px; }
h1 { font-size:16px;margin:0; }
.id,.when { color:var(--muted);font:11px/1.4 var(--font-mono); }
/* conversation list */
.convo-list { display:grid;gap:6px; }
.convo { display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:10px;align-items:center;text-decoration:none;color:inherit;border:1px solid transparent;border-radius:8px;padding:8px; }
.convo:hover { background:var(--raised); }
.convo.active { background:var(--raised);border-color:var(--border); }
.pair { display:inline-flex; }
.pair .agent-avatar:nth-child(2) { margin-left:-8px;box-shadow:0 0 0 2px var(--surface); }
.convo-main { min-width:0;display:grid;gap:2px; }
.convo-names { font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.convo-snippet { color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.convo-meta { display:flex;flex-direction:column;align-items:flex-end;gap:4px; }
.badge { background:var(--accent);color:#fff;border-radius:999px;font:10px/1 var(--font-mono);padding:3px 6px;min-width:16px;text-align:center; }
/* switcher */
.switch { display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-bottom:10px;color:var(--muted);font-size:11px; }
.as-link { text-decoration:none;color:var(--muted);border:1px solid var(--border);border-radius:999px;padding:2px 9px;font:11px/1.4 var(--font-mono); }
.as-link.active { color:var(--text);border-color:var(--accent);background:rgba(88,166,255,.10); }
/* thread */
.thread { display:grid;gap:8px;max-height:66vh;overflow-y:auto; }
.bubble-row { display:flex; }
.bubble-row.sent { justify-content:flex-end; }
.bubble-row.recv { justify-content:flex-start; }
.bubble-row.reply .bubble { margin-left:28px; }
.bubble-row.sent.reply .bubble { margin-left:0;margin-right:28px; }
.bubble { max-width:76%;background:var(--raised);border:1px solid var(--border);border-radius:12px;padding:8px 11px;min-width:0; }
.bubble-row.sent .bubble { background:rgba(88,166,255,.12);border-color:rgba(88,166,255,.32); }
.bubble-head { display:flex;align-items:center;gap:7px;margin-bottom:4px; }
.from { font-size:12px;font-weight:600; }
.task-pill { color:var(--status-review);border:1px solid rgba(210,153,34,.4);border-radius:999px;padding:0 6px;font:10px/1.5 var(--font-mono); }
.bubble-text { overflow-wrap:anywhere;font-size:13px;line-height:1.5; }
.bubble-foot { display:flex;gap:8px;align-items:center;margin-top:5px; }
.rstat { font:10px/1.4 var(--font-mono);color:var(--muted); }
.rstat[data-status="read"] { color:var(--accent); }
.rstat[data-status="acked"] { color:var(--green); }
.empty { color:var(--muted);font-size:12px;padding:8px; }
@media (max-width:860px){ main{grid-template-columns:1fr} .thread{max-height:none} }
</style>
</head>
<body>
${appHeader(config.projectName, 'messages')}
<main>
<section class="panel">
<div class="panel-head"><h1>Conversations</h1><span class="when">${convoList.length}</span></div>
<div class="convo-list">${convoRows}</div>
</section>
<section class="panel">
<div class="switch"><span>View as</span>${switcher}</div>
<div class="panel-head"><h1>${headerTitle}</h1></div>
<div class="thread">${thread}</div>
</section>
</main>
${taskModalHtml()}
${appHeaderJs()}
<script>
(function(){
if(!('EventSource' in window)) return;
var t=null;
try {
var s=new EventSource('/events');
s.onmessage=function(ev){
try { var p=JSON.parse(ev.data); if(p&&p.type==='message'){ if(t)clearTimeout(t); t=setTimeout(function(){ location.reload(); }, 400); } } catch(_){}
};
window.addEventListener('pagehide', function(){ try{ s.close(); }catch(_){} });
} catch(_){}
})();
</script>
</body>
</html>`;
}

View File

@ -4,8 +4,7 @@ import { getTaskActivity } from '../core/services/activityService.js';
import { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js'; import { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js';
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js'; import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
import { createDecision, listDecisions } from '../core/services/decisionService.js'; import { createDecision, listDecisions } from '../core/services/decisionService.js';
import { createMessage, listMessages, listInbox, markMessageRead, ackMessage, getMessage } from '../core/services/messageService.js'; import { createMessage, listMessages, listInbox, markMessageRead } from '../core/services/messageService.js';
import { createAsk, listAsks, getAsk, answerAsk, escalateAsk } from '../core/services/askService.js';
import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js'; import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js';
import { getStatus, updateStatus } from '../core/services/statusService.js'; import { getStatus, updateStatus } from '../core/services/statusService.js';
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js'; import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
@ -13,15 +12,14 @@ import { computeBudget } from '../core/services/budgetService.js';
import { getRoster } from '../core/services/rosterService.js'; import { getRoster } from '../core/services/rosterService.js';
import { loadConfig, saveConfig } from '../core/config.js'; import { loadConfig, saveConfig } from '../core/config.js';
import { renderActivityHtml } from './activity.js'; import { renderActivityHtml } from './activity.js';
import { renderBoardHtml } from './board/index.js'; import { renderBoardHtml } from './board.js';
import { renderTeamHtml } from './team.js'; import { renderTeamHtml } from './team.js';
import { renderArchiveHtml } from './archive.js'; import { renderArchiveHtml } from './archive.js';
import { renderDecisionsHtml } from './decisions.js'; import { renderDecisionsHtml } from './decisions.js';
import { renderMessagesHtml } from './messages.js';
import { renderTaskDetailHtml } from './taskDetail.js'; import { renderTaskDetailHtml } from './taskDetail.js';
import { eventBus, emitChange } from './events.js'; import { eventBus, emitChange } from './events.js';
import type { AgentHubEvent } from './events.js'; import type { AgentHubEvent } from './events.js';
import type { Task, Handoff, Decision, Memory, Message, Ask } from '../core/schema.js'; import type { Task, Handoff, Decision, Memory, Message } from '../core/schema.js';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { join, extname, normalize } from 'node:path'; import { join, extname, normalize } from 'node:path';
@ -46,11 +44,14 @@ function wantsHtml(request: { headers: { accept?: string } }): boolean {
} }
export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> { export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> {
// Default dynamic responses to no-store so board reloads/fetches never reuse // Never let a browser cache a hub HTML page — otherwise a stale board/team
// stale task JSON or HTML. Static asset routes override this with cacheable // page keeps showing old markup and doesn't reflect live task/agent state.
// headers, and the SSE route writes its own raw no-cache header. app.addHook('onSend', async (_request, reply, payload) => {
app.addHook('onRequest', async (_request, reply) => { const ct = reply.getHeader('content-type');
reply.header('Cache-Control', 'no-store, must-revalidate'); if (typeof ct === 'string' && ct.includes('text/html')) {
reply.header('Cache-Control', 'no-store, must-revalidate');
}
return payload;
}); });
// Static, self-contained Trello-like board. Polls /tasks, /handoffs and // Static, self-contained Trello-like board. Polls /tasks, /handoffs and
@ -59,16 +60,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
const boardHtml = renderBoardHtml(loadConfig(cwd).projectName); const boardHtml = renderBoardHtml(loadConfig(cwd).projectName);
app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml)); app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml));
// AgentHub logo (header, splash, favicon). Read-only, cacheable.
app.get('/logo.svg', async (_request, reply) => {
try {
const buf = await readFile(join(CARD_ASSETS_DIR, 'logo.svg'));
return reply.type('image/svg+xml').header('Cache-Control', 'public, max-age=3600').send(buf);
} catch {
return reply.status(404).send('not found');
}
});
// Serve the KPI-card Lottie animations + the vendored lottie-web player from the // Serve the KPI-card Lottie animations + the vendored lottie-web player from the
// repo `assets/` dir. Read-only, path-traversal-guarded, cacheable. // repo `assets/` dir. Read-only, path-traversal-guarded, cacheable.
app.get('/card-assets/*', async (request, reply) => { app.get('/card-assets/*', async (request, reply) => {
@ -221,19 +212,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// Token & cost rollup per agent (real recorded + time-estimated, clearly flagged). // Token & cost rollup per agent (real recorded + time-estimated, clearly flagged).
app.get('/budget', async () => computeBudget(cwd)); app.get('/budget', async () => computeBudget(cwd));
// Auto-log a task status transition to its live console. Best-effort: the
// .log write + task-log SSE fan-out must never fail the underlying mutation.
// (No double-emission: publishLog rides the separate 'log' channel, and
// fsWatch only watches .md files, so the .log append is not re-emitted.)
const logTaskStatus = (id: string, text: string, agent?: string) => {
try {
const entry = appendTaskLog(cwd, id, { text, agent, level: 'status' });
eventBus.publishLog({ taskId: id, ...entry });
} catch {
/* logging is best-effort */
}
};
// ─── Tasks ─────────────────────────────────────────────────────────────── // ─── Tasks ───────────────────────────────────────────────────────────────
app.get('/tasks', async (request) => { app.get('/tasks', async (request) => {
const { status, role } = request.query as { status?: string; role?: string }; const { status, role } = request.query as { status?: string; role?: string };
@ -256,7 +234,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
status: task.status, status: task.status,
role: task.role, role: task.role,
assignedTo: task.assignedTo, assignedTo: task.assignedTo,
claimedBy: task.claimedBy,
reviewer: task.reviewer, reviewer: task.reviewer,
}, },
task.updatedAt, task.updatedAt,
@ -326,7 +303,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// change). Fires task/updated so a waiting `agenthub work` auto-claims it. // change). Fires task/updated so a waiting `agenthub work` auto-claims it.
if (patch.assignedTo !== undefined && patch.status === undefined) { if (patch.assignedTo !== undefined && patch.status === undefined) {
const assigned = assignTask(cwd, id, patch.assignedTo); const assigned = assignTask(cwd, id, patch.assignedTo);
logTaskStatus(id, `Addressed to ${assigned.assignedTo}`, assigned.assignedTo);
emitChange( emitChange(
{ {
type: 'task', type: 'task',
@ -336,7 +312,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
status: assigned.status, status: assigned.status,
role: assigned.role, role: assigned.role,
assignedTo: assigned.assignedTo, assignedTo: assigned.assignedTo,
claimedBy: assigned.claimedBy,
reviewer: assigned.reviewer, reviewer: assigned.reviewer,
}, },
assigned.updatedAt, assigned.updatedAt,
@ -347,18 +322,8 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
let task: Task; let task: Task;
switch (patch.status) { switch (patch.status) {
case 'in_progress': case 'in_progress':
{ if (!patch.assignedTo) return badRequest(reply, 'assignedTo is required to claim a task (in_progress)');
const current = getTask(cwd, id).task; task = claimTask(cwd, id, patch.assignedTo);
const agent = patch.assignedTo?.trim() || current.assignedTo || 'manual';
// claimTask is race-guarded (open-only). Surface a lost race / non-open
// claim as a clean 400 so the board drag reverts gracefully instead of
// 500-ing, and a second agent can't clobber the first's claim.
try {
task = claimTask(cwd, id, agent);
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Cannot claim task');
}
}
break; break;
case 'done': case 'done':
task = doneTask(cwd, id, { task = doneTask(cwd, id, {
@ -380,17 +345,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled'); return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled');
} }
// Uniformly log the transition for every status-changing caller (claim /
// review / done / cancel / reopen), so the live console tracks progress.
const logText =
task.status === 'in_progress' ? `Claimed by ${task.claimedBy ?? task.assignedTo ?? 'agent'}`
: task.status === 'review' ? `Submitted for review${task.reviewer ? `${task.reviewer}` : ''}`
: task.status === 'done' ? `Approved — done${task.doneBy ? ` by ${task.doneBy}` : ''}`
: task.status === 'cancelled' ? 'Cancelled'
: task.status === 'open' ? 'Reopened'
: `Status → ${task.status}`;
logTaskStatus(id, logText, task.claimedBy ?? task.assignedTo ?? task.reviewer ?? task.doneBy);
emitChange( emitChange(
{ {
type: 'task', type: 'task',
@ -400,7 +354,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
status: task.status, status: task.status,
role: task.role, role: task.role,
assignedTo: task.assignedTo, assignedTo: task.assignedTo,
claimedBy: task.claimedBy,
reviewer: task.reviewer, reviewer: task.reviewer,
}, },
task.updatedAt, task.updatedAt,
@ -470,14 +423,9 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// Direct agent-to-agent / architect-to-agent messages. GET /messages returns // Direct agent-to-agent / architect-to-agent messages. GET /messages returns
// all (architect view); GET /messages/inbox?agent=X&unread=1 returns one // all (architect view); GET /messages/inbox?agent=X&unread=1 returns one
// agent's inbox. // agent's inbox.
app.get('/messages', async (request, reply) => { app.get('/messages', async (request) => {
const { agent, unread, as, c } = request.query as { agent?: string; unread?: string; as?: string; c?: string }; const { agent, unread } = request.query as { agent?: string; unread?: string };
// JSON inbox contract (remoteClient.getInbox / agenthub_inbox) — unchanged.
if (agent) return listInbox(cwd, agent, { unreadOnly: unread === '1' || unread === 'true' }); if (agent) return listInbox(cwd, agent, { unreadOnly: unread === '1' || unread === 'true' });
// Browser navigation → the /messages conversation view.
if (wantsHtml(request)) {
return reply.type('text/html; charset=utf-8').send(renderMessagesHtml(cwd, as, c));
}
return listMessages(cwd); return listMessages(cwd);
}); });
app.post('/messages', async (request, reply) => { app.post('/messages', async (request, reply) => {
@ -493,23 +441,11 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
action: 'created', action: 'created',
id: message.id, id: message.id,
title: `${message.from}${message.to}`, title: `${message.from}${message.to}`,
assignedTo: message.to,
}, },
message.updatedAt, message.updatedAt,
); );
return message; return message;
}); });
// Single message (frontmatter + body) — used by `message reply` to load the
// parent it answers. JSON only.
app.get('/messages/:id', async (request, reply) => {
const { id } = request.params as { id: string };
try {
const { message, body } = getMessage(cwd, id);
return { message, body };
} catch {
return notFound(reply, 'Message');
}
});
app.post('/messages/:id/read', async (request, reply) => { app.post('/messages/:id/read', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
try { try {
@ -525,80 +461,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
return badRequest(reply, err instanceof Error ? err.message : 'Message not found'); return badRequest(reply, err instanceof Error ? err.message : 'Message not found');
} }
}); });
app.post('/messages/:id/ack', async (request, reply) => {
const { id } = request.params as { id: string };
const { by } = (request.body ?? {}) as { by?: string };
try {
const message = ackMessage(cwd, id, by);
// Ack receipt: mirror of /read so the sender's stream shows the strongest state.
emitChange(
{ type: 'message', action: 'updated', id: message.id, status: 'acked', title: `${message.from}${message.to}`, assignedTo: message.to },
message.updatedAt,
);
return message;
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Message not found');
}
});
// ─── Asks ────────────────────────────────────────────────────────────────
// Blocking decision-routing questions (TSK-0118). POST creates + routes to the
// architect; answer/escalate close them. Each mutation fires an 'ask' event so
// a waiting `ask --wait` / agenthub_ask wakes.
app.get('/asks', async (request) => {
const { to, status } = request.query as { to?: string; status?: string };
return listAsks(cwd, { to, status });
});
app.post('/asks', async (request, reply) => {
let ask: Ask;
try {
ask = createAsk(cwd, request.body as Partial<Ask>);
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Invalid ask');
}
emitChange(
{ type: 'ask', action: 'created', id: ask.id, title: `${ask.from}${ask.to}`, status: ask.status, assignedTo: ask.to },
ask.updatedAt,
);
return ask;
});
app.get('/asks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
try {
const { ask, body } = getAsk(cwd, id);
return { ask, body };
} catch {
return notFound(reply, 'Ask');
}
});
app.post('/asks/:id/answer', async (request, reply) => {
const { id } = request.params as { id: string };
const { text, by } = (request.body ?? {}) as { text?: string; by?: string };
try {
const ask = answerAsk(cwd, id, text ?? '', by);
emitChange(
{ type: 'ask', action: 'updated', id: ask.id, title: `${ask.from}${ask.to}`, status: ask.status, assignedTo: ask.to },
ask.updatedAt,
);
return ask;
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Ask not found');
}
});
app.post('/asks/:id/escalate', async (request, reply) => {
const { id } = request.params as { id: string };
const { note } = (request.body ?? {}) as { note?: string };
try {
const ask = escalateAsk(cwd, id, note);
emitChange(
{ type: 'ask', action: 'updated', id: ask.id, title: `${ask.from}${ask.to}`, status: ask.status, assignedTo: ask.escalatedTo },
ask.updatedAt,
);
return ask;
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Ask not found');
}
});
// ─── Memory ────────────────────────────────────────────────────────────── // ─── Memory ──────────────────────────────────────────────────────────────
app.get('/memory', async () => listMemory(cwd)); app.get('/memory', async () => listMemory(cwd));

View File

@ -3,7 +3,6 @@ import { getTask } from '../core/services/taskService.js';
import { getTaskActivity } from '../core/services/activityService.js'; import { getTaskActivity } from '../core/services/activityService.js';
import { getDecision } from '../core/services/decisionService.js'; import { getDecision } from '../core/services/decisionService.js';
import { getHandoff, listHandoffs } from '../core/services/handoffService.js'; import { getHandoff, listHandoffs } from '../core/services/handoffService.js';
import { readTaskLog, type TaskLogEntry } from '../core/services/taskLogService.js';
import { agentAvatar, designTokensCss, escapeHtml, statusPill, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js'; import { agentAvatar, designTokensCss, escapeHtml, statusPill, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
import type { ActivityItem, Decision, Handoff } from '../core/schema.js'; import type { ActivityItem, Decision, Handoff } from '../core/schema.js';
@ -128,13 +127,6 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
.join('') .join('')
: '<div class="empty">No linked decisions for this task.</div>'; : '<div class="empty">No linked decisions for this task.</div>';
const logEntries: TaskLogEntry[] = readTaskLog(cwd, id);
const logLine = (e: TaskLogEntry) =>
`<div class="log-line" data-level="${escapeHtml(e.level ?? 'info')}"><span class="log-ts">${escapeHtml(ago(e.ts))}</span>${e.agent ? `<span class="log-agent">${escapeHtml(e.agent)}</span>` : ''}<span class="log-text">${escapeHtml(e.text)}</span></div>`;
const consoleRows = logEntries.length
? logEntries.map(logLine).join('')
: '<div class="empty" data-empty>No console output yet.</div>';
const activityRows = activity.length const activityRows = activity.length
? activity ? activity
.map( .map(
@ -186,14 +178,6 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
.kind { color:var(--accent);font:11px/1.4 var(--font-mono); } .kind { color:var(--accent);font:11px/1.4 var(--font-mono); }
.summary { min-width:0;overflow-wrap:anywhere; } .summary { min-width:0;overflow-wrap:anywhere; }
.empty { color:var(--muted); } .empty { color:var(--muted); }
.console { max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:2px;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px;font:12px/1.5 var(--font-mono); }
.log-line { display:flex;gap:8px;align-items:baseline;overflow-wrap:anywhere; }
.log-ts { color:var(--muted);white-space:nowrap;flex:0 0 auto; }
.log-agent { color:var(--accent);white-space:nowrap;flex:0 0 auto; }
.log-text { color:var(--text);min-width:0; }
.log-line[data-level="status"] .log-text { color:var(--status-review); }
.log-line[data-level="warn"] .log-text { color:var(--status-review); }
.log-line[data-level="error"] .log-text { color:#F85149; }
@media (max-width:640px){ .activity-row{grid-template-columns:1fr}.actor{white-space:normal} } @media (max-width:640px){ .activity-row{grid-template-columns:1fr}.actor{white-space:normal} }
</style> </style>
</head> </head>
@ -206,10 +190,6 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
<div class="stats">${taskStats}</div> <div class="stats">${taskStats}</div>
${body.trim() ? `<pre>${escapeHtml(body.trim())}</pre>` : ''} ${body.trim() ? `<pre>${escapeHtml(body.trim())}</pre>` : ''}
</section> </section>
<section class="panel">
<h2>Live Console</h2>
<div class="console" id="taskConsole">${consoleRows}</div>
</section>
<section class="panel"> <section class="panel">
<h2>Handoffs</h2> <h2>Handoffs</h2>
${handoffRows} ${handoffRows}
@ -225,26 +205,6 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
</main> </main>
${taskModalHtml()} ${taskModalHtml()}
${appHeaderJs()} ${appHeaderJs()}
<script>
(function(){
var TASK_ID = ${JSON.stringify(task.id)};
var box = document.getElementById('taskConsole');
if(!box || !('EventSource' in window)) return;
function fmtAgo(iso){ var t=Date.parse(iso); if(isNaN(t))return''; var s=Math.max(0,Math.floor((Date.now()-t)/1000)); if(s<60)return s+'s'; var m=Math.floor(s/60); if(m<60)return m+'m'; var h=Math.floor(m/60); if(h<24)return h+'h'; return Math.floor(h/24)+'d'; }
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function append(p){
var empty=box.querySelector('[data-empty]'); if(empty) empty.remove();
var el=document.createElement('div'); el.className='log-line'; el.setAttribute('data-level', p.level||'info');
el.innerHTML='<span class="log-ts">'+esc(fmtAgo(p.ts||new Date().toISOString()))+' ago</span>'+(p.agent?'<span class="log-agent">'+esc(p.agent)+'</span>':'')+'<span class="log-text">'+esc(p.text)+'</span>';
box.appendChild(el); box.scrollTop = box.scrollHeight;
}
try {
var s=new EventSource('/events');
s.addEventListener('task-log', function(ev){ try{ var p=JSON.parse(ev.data); if(p && p.taskId===TASK_ID) append(p); }catch(_){} });
window.addEventListener('pagehide', function(){ try{ s.close(); }catch(_){} });
} catch(_){}
})();
</script>
</body> </body>
</html>`; </html>`;
} }

View File

@ -291,74 +291,43 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act
// identically on the non-board pages. // identically on the non-board pages.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export type HeaderPage = 'board' | 'team' | 'activity' | 'messages' | 'decisions' | 'archive' | 'task'; export type HeaderPage = 'board' | 'team' | 'activity' | 'decisions' | 'archive' | 'task';
/** CSS for ONLY the shared header (board v2 reuses this inside boardV2Css). */
export function appHeaderOnlyCss(): string {
return `
/* v2 header tokens (self-contained — host pages define different palettes) */
:root {
--b2-border: rgba(255,255,255,.09);
--b2-surface: rgba(255,255,255,.045);
--b2-text: #eef1f8;
--b2-muted: #8fa0c6;
--b2-green: #34d399;
--b2-amber: #fbbf24;
}
@keyframes b2-ping { 0% { box-shadow: 0 0 0 0 rgba(52,211,179,.55); } 70% { box-shadow: 0 0 0 7px rgba(52,211,179,0); } 100% { box-shadow: 0 0 0 0 rgba(52,211,179,0); } }
.app-header {
position: fixed; top: 0; left: 0; right: 0; z-index: 40;
display: flex; align-items: center; gap: 14px; min-height: 66px;
margin: 0; padding: 10px 22px;
border-bottom: 1px solid rgba(255,255,255,.06);
background: rgba(9, 12, 24, .82); backdrop-filter: blur(12px);
}
.app-header .b2-brand { display: flex; align-items: center; gap: 10px; font-size: 16px; }
.app-header .b2-brand b { font-weight: 700; letter-spacing: .01em; }
.b2-logo-img { display: block; }
.app-header .b2-proj {
display: flex; align-items: center; gap: 5px;
color: var(--b2-muted); font-size: 13px; font-family: var(--font-mono, ui-monospace, monospace);
padding: 4px 10px; border-radius: 8px; border: 1px solid transparent;
max-width: 34vw; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.app-header .b2-proj:hover { background: rgba(255,255,255,.05); border-color: var(--b2-border); color: #c6d0e8; }
.b2-chev { font-size: 9px; opacity: .7; }
.app-header .b2-nav { display: flex; align-items: center; gap: 2px; margin-left: 8px;
border: 1px solid var(--b2-border); background: var(--b2-surface); padding: 3px; border-radius: 9px; }
.app-header .b2-nav a {
min-height: 32px; display: inline-flex; align-items: center;
padding: 0 13px; border-radius: 7px; font-size: 13px; font-weight: 500;
color: var(--b2-muted); text-decoration: none; transition: color 160ms ease, background 160ms ease;
}
.app-header .b2-nav a:hover { color: var(--b2-text); background: rgba(255,255,255,.05); }
.app-header .b2-nav a.active { color: var(--b2-text); background: rgba(255,255,255,.08); font-weight: 600; }
.b2-hdr-right { margin-left: auto; display: flex; align-items: center; gap: 14px; }
.b2-btn {
display: inline-flex; align-items: center; gap: 7px;
min-height: 36px; padding: 0 16px; border: 0; border-radius: 9px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff; font: 600 13px/1 var(--font-sans, system-ui, sans-serif); cursor: pointer;
box-shadow: 0 4px 18px rgba(99,102,241,.28);
transition: transform 120ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.b2-btn:hover { filter: brightness(1.1); box-shadow: 0 6px 22px rgba(99,102,241,.38); }
.b2-btn:active { transform: translateY(1px); }
.sse-status { display: inline-flex; align-items: center; gap: 7px; min-width: 116px; min-height: 34px; color: var(--b2-muted); font: 12px/1 var(--font-mono, ui-monospace, monospace); white-space: nowrap; }
.b2-live-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--b2-green); animation: b2-ping 2s infinite; }
.sse-status.stale .b2-live-dot { background: var(--b2-amber); animation: none; }
.sse-status.down .b2-live-dot { background: #F85149; animation: none; }
@media (max-width: 680px) {
.app-header { flex-wrap: wrap; gap: 10px 12px; }
.b2-hdr-right { margin-left: auto; }
.app-header .b2-nav { order: 3; width: 100%; }
.app-header .b2-nav a { flex: 1; justify-content: center; }
}`;
}
/** CSS for the shared header + new-task modal + toasts. Include once per page. */ /** CSS for the shared header + new-task modal + toasts. Include once per page. */
export function appHeaderCss(): string { export function appHeaderCss(): string {
return appHeaderOnlyCss() + ` return `
.app-header {
position: fixed; top: 0; left: 0; right: 0; z-index: 40;
display: flex; align-items: center; gap: 16px; min-height: 64px;
margin: 0; padding: 10px 20px;
border-bottom: 1px solid var(--border);
background: rgba(15, 23, 42, .96); backdrop-filter: blur(10px);
}
.app-header .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
.app-header .mark { width: 34px; height: 34px; flex: 0 0 auto; border: 1px solid rgba(88,166,255,.38); border-radius: 8px; display: grid; place-items: center; background: var(--raised); }
.app-header .mark svg { width: 22px; height: 22px; }
.brand-copy { min-width: 0; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.brand-title { font-weight: 700; font-size: 17px; }
.project-name { color: var(--muted); font-size: 12px; font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42vw; }
.header-spacer { flex: 1; }
.app-header .nav { display: flex; align-items: center; gap: 4px; border: 1px solid var(--border); background: var(--surface); padding: 3px; border-radius: 8px; }
.app-header .nav-link { min-height: 34px; display: inline-flex; align-items: center; padding: 0 12px; border-radius: 6px; color: var(--muted); font-size: 13px; text-decoration: none; transition: color 180ms ease, background 180ms ease; }
.app-header .nav-link:hover, .app-header .nav-link.active { color: var(--text); background: var(--raised); }
.new-task-btn { display: inline-flex; align-items: center; gap: 7px; min-height: 34px; padding: 0 14px; border-radius: 8px; border: 1px solid rgba(88,166,255,.45); background: linear-gradient(180deg, rgba(88,166,255,.20), rgba(88,166,255,.10)); color: var(--text); font: 600 13px/1 var(--font-sans); cursor: pointer; transition: background 160ms ease, transform 120ms ease, box-shadow 160ms ease; }
.new-task-btn:hover { background: rgba(88,166,255,.28); box-shadow: 0 4px 16px rgba(88,166,255,.18); }
.new-task-btn:active { transform: translateY(1px); }
.new-task-btn .plus { font-size: 16px; line-height: 1; margin-top: -1px; }
.sse-status { display: inline-flex; align-items: center; gap: 7px; min-width: 116px; min-height: 34px; color: var(--muted); font: 12px/1 var(--font-mono); white-space: nowrap; }
.conn-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,.16); }
.sse-status.stale .conn-dot { background: var(--status-review); box-shadow: 0 0 0 3px rgba(210,153,34,.14); }
.sse-status.down .conn-dot { background: #F85149; box-shadow: 0 0 0 3px rgba(248,81,73,.14); }
@media (max-width: 680px) {
.app-header { flex-wrap: wrap; gap: 10px 12px; }
.header-spacer { display: none; }
.app-header .nav { order: 3; width: 100%; }
.app-header .nav-link { flex: 1; justify-content: center; }
}
/* ── New-task modal ─────────────────────────────────────────────────── */ /* ── New-task modal ─────────────────────────────────────────────────── */
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start; justify-content: center; padding: 12vh 16px 16px; background: rgba(2,6,18,.62); backdrop-filter: blur(3px); animation: modalFade 140ms ease; } .modal-backdrop { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start; justify-content: center; padding: 12vh 16px 16px; background: rgba(2,6,18,.62); backdrop-filter: blur(3px); animation: modalFade 140ms ease; }
.modal-backdrop[hidden] { display: none; } .modal-backdrop[hidden] { display: none; }
@ -397,27 +366,33 @@ export function appHeaderCss(): string {
@keyframes toastOut { to { opacity: 0; transform: translateY(6px); } }`; @keyframes toastOut { to { opacity: 0; transform: translateY(6px); } }`;
} }
/** The shared header markup (v2): logo, project cell, nav, New-task, live status. */ /** The shared header markup: brand, nav, New-task button, live status. */
export function appHeader(projectName: string, current: HeaderPage): string { export function appHeader(projectName: string, current: HeaderPage): string {
const mark = `<span class="mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none"><path d="M5 7.5h8.5a5.5 5.5 0 0 1 0 11H5v-11Z" stroke="#58A6FF" stroke-width="1.8"/><path d="M8.5 5.5h7a4 4 0 0 1 0 8h-7v-8Z" stroke="#22C55E" stroke-width="1.8"/></svg></span>`;
const link = (label: string, path: string, key: HeaderPage) => const link = (label: string, path: string, key: HeaderPage) =>
`<a class="${current === key ? 'active' : ''}" href="${path}"${current === key ? ' aria-current="page"' : ''}>${label}</a>`; `<a class="nav-link${current === key ? ' active' : ''}" href="${path}">${label}</a>`;
return ` return `
<header class="app-header"> <header class="app-header">
<span class="b2-brand"><img src="/logo.svg" alt="" width="24" height="24" class="b2-logo-img"><b>agenthub</b></span> <div class="brand">
<span class="b2-proj" title="Projektwechsel kommt mit v3">/ <span id="projectName">${escapeHtml(projectName)}</span>&nbsp;<span class="b2-chev"></span></span> ${mark}
<nav class="b2-nav" aria-label="Primary"> <div class="brand-copy">
<span class="brand-title">AgentHub</span>
<span class="project-name" id="projectName">${escapeHtml(projectName)}</span>
</div>
</div>
<span class="header-spacer"></span>
<nav class="nav" aria-label="Primary">
${link('Board', '/board', 'board')} ${link('Board', '/board', 'board')}
${link('Team', '/team', 'team')} ${link('Team', '/team', 'team')}
${link('Activity', '/activity', 'activity')} ${link('Activity', '/activity', 'activity')}
${link('Messages', '/messages', 'messages')}
${link('Decisions', '/decisions', 'decisions')} ${link('Decisions', '/decisions', 'decisions')}
</nav> </nav>
<span class="b2-hdr-right"> <button class="new-task-btn" id="newTaskBtn" type="button" aria-haspopup="dialog" aria-expanded="false">
<button class="b2-btn" id="newTaskBtn" type="button" aria-haspopup="dialog" aria-expanded="false">+ New task</button> <span class="plus" aria-hidden="true">+</span> New task
<span class="sse-status stale" id="sseStatus"> </button>
<span class="b2-live-dot" aria-hidden="true"></span> <span class="sse-status stale" id="sseStatus">
<span id="sseLabel">connecting</span> <span class="conn-dot" aria-hidden="true"></span>
</span> <span id="sseLabel">connecting</span>
</span> </span>
</header>`; </header>`;
} }

View File

@ -1,93 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { buildApp } from '../src/server/index.js';
import { startServer } from '../src/server/index.js';
import { waitForAsk } from '../src/cli/commands/ask.js';
import type { Ask } from '../src/core/schema.js';
describe('ask routes (TSK-0118)', () => {
let cwd: string;
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-ask-routes-'));
init(cwd, { projectName: 'ask-routes', yes: true });
app = buildApp(cwd);
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('POST/GET/answer/escalate roundtrip', async () => {
const created = JSON.parse((await app.inject({ method: 'POST', url: '/asks', payload: { from: 'kimi', question: 'q?' } })).payload) as Ask;
expect(created.id).toBe('ASK-0001');
expect(created.to).toBe('claude');
expect(created.status).toBe('pending');
// GET /asks — the data source agenthub_work's architect branch surfaces.
const pending = JSON.parse((await app.inject({ method: 'GET', url: '/asks?status=pending' })).payload) as Ask[];
expect(pending.map((a) => a.id)).toContain('ASK-0001');
const answered = JSON.parse((await app.inject({ method: 'POST', url: '/asks/ASK-0001/answer', payload: { text: 'yes', by: 'claude' } })).payload) as Ask;
expect(answered.status).toBe('answered');
expect(answered.answer).toBe('yes');
const created2 = JSON.parse((await app.inject({ method: 'POST', url: '/asks', payload: { from: 'kimi', question: 'ship?' } })).payload) as Ask;
const escalated = JSON.parse((await app.inject({ method: 'POST', url: `/asks/${created2.id}/escalate`, payload: { note: 'ceo call' } })).payload) as Ask;
expect(escalated.status).toBe('escalated');
expect(escalated.escalatedTo).toBe('ceo');
});
});
describe('ask --wait (server SSE)', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-ask-wait-'));
init(cwd, { projectName: 'ask-wait', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
await server.app.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
});
it('resolves with the answer when the architect answers', async () => {
const created = (await fetch(`${server.url}/asks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'kimi', question: 'which driver?' }),
}).then((r) => r.json())) as Ask;
const waitP = waitForAsk(server.url, created.id, 4);
await new Promise((r) => setTimeout(r, 200));
await fetch(`${server.url}/asks/${created.id}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'node:sqlite', by: 'claude' }),
});
const settled = await waitP;
expect(settled).not.toBeNull();
expect(settled?.status).toBe('answered');
expect(settled?.answer).toBe('node:sqlite');
}, 7000);
it('times out cleanly when there is no answer', async () => {
const created = (await fetch(`${server.url}/asks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'kimi', question: 'no answer coming' }),
}).then((r) => r.json())) as Ask;
const settled = await waitForAsk(server.url, created.id, 1);
expect(settled).toBeNull();
}, 4000);
});

View File

@ -1,80 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { createAsk, answerAsk, escalateAsk, listAsks, getAsk } from '../src/core/services/askService.js';
import { askCreate, askAnswer, askEscalate } from '../src/cli/commands/ask.js';
import { addMemory, searchMemory } from '../src/core/services/memoryService.js';
describe('askService (TSK-0118)', () => {
let cwd: string;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-ask-'));
await init(cwd, { yes: true, projectName: 'test' });
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('routes to the architect (never the CEO) by default', () => {
const ask = createAsk(cwd, { from: 'kimi', question: 'Which DB driver?' });
expect(ask.id).toMatch(/^ASK-\d{4}$/);
expect(ask.to).toBe('claude'); // config.roles.architect.preferredAgent
expect(ask.status).toBe('pending');
});
it('refuses to route to the CEO — reroutes to the architect', () => {
const ask = createAsk(cwd, { from: 'kimi', to: 'ceo', question: 'ship it?' });
expect(ask.to).toBe('claude');
});
it('answerAsk closes it as answered with the answer + author', () => {
const ask = createAsk(cwd, { from: 'kimi', question: 'Which DB driver?' });
const answered = answerAsk(cwd, ask.id, 'node:sqlite', 'claude');
expect(answered.status).toBe('answered');
expect(answered.answer).toBe('node:sqlite');
expect(answered.answeredBy).toBe('claude');
expect(getAsk(cwd, ask.id).ask.status).toBe('answered');
});
it('escalateAsk flips to escalated → ceo (single channel, no second ask)', () => {
const ask = createAsk(cwd, { from: 'kimi', question: 'Publish to OSS?' });
const escalated = escalateAsk(cwd, ask.id, 'OSS decision — CEO call');
expect(escalated.status).toBe('escalated');
expect(escalated.escalatedTo).toBe('ceo');
});
it('listAsks filters by status and recipient', () => {
const a1 = createAsk(cwd, { from: 'kimi', question: 'q1' });
createAsk(cwd, { from: 'codex', question: 'q2' });
answerAsk(cwd, a1.id, 'yes');
expect(listAsks(cwd)).toHaveLength(2);
expect(listAsks(cwd, { status: 'pending' })).toHaveLength(1);
expect(listAsks(cwd, { to: 'claude' })).toHaveLength(2);
expect(listAsks(cwd, { to: 'nobody' })).toHaveLength(0);
});
it('asks are NOT indexed in FTS5 (memory search never returns them)', () => {
createAsk(cwd, { from: 'kimi', question: 'znamqvist widget architecture' });
addMemory(cwd, { title: 'note', content: 'znamqvist widget architecture' });
const hits = searchMemory(cwd, 'znamqvist');
expect(hits.some((r) => r.type === 'ask')).toBe(false); // ask excluded from FTS
expect(hits.some((r) => r.type === 'memory')).toBe(true); // search still works
});
it('ask-cmd helpers create/answer/escalate through the service', () => {
const ask = askCreate(cwd, { from: 'kimi', question: 'via cmd?' });
expect(ask.status).toBe('pending');
askAnswer(cwd, ask.id, 'ok', 'claude');
expect(getAsk(cwd, ask.id).ask.status).toBe('answered');
const ask2 = askCreate(cwd, { from: 'kimi', question: 'escalate me' });
askEscalate(cwd, ask2.id, 'needs ceo');
expect(getAsk(cwd, ask2.id).ask.status).toBe('escalated');
});
});

View File

@ -1,35 +0,0 @@
import { describe, it, expect } from 'vitest';
import { headerHtml, splashHtml, splashJs } from '../src/server/board/chrome.js';
describe('headerHtml', () => {
it('renders logo, project cell with chevron, nav and new-task button', () => {
const h = headerHtml('my-project');
expect(h).toContain('/logo.svg');
expect(h).toContain('my-project');
expect(h).toContain('b2-proj'); // project cell (visual only, dropdown comes with v3)
expect(h).toContain('▾');
expect(h).toContain('+ New task');
expect(h).toContain('id="newTaskBtn"');
// SSE status mount compatible with the ported v1 connection JS
expect(h).toContain('id="sseStatus"');
expect(h).toContain('id="sseLabel"');
});
it('escapes the project name', () => {
expect(headerHtml('<b>x</b>')).not.toContain('<b>x</b>');
});
});
describe('splash', () => {
it('renders overlay with logo and wordmark', () => {
const s = splashHtml();
expect(s).toContain('id="b2-splash"');
expect(s).toContain('/logo.svg');
expect(s).toContain('agenthub');
});
it('fade-out script has a hard timeout and never blocks', () => {
const js = splashJs();
expect(js).toContain('b2-splash');
expect(js).toContain('1500'); // hard cap in ms
expect(js).toContain('__b2SplashDone');
});
});

View File

@ -1,30 +0,0 @@
import { describe, it, expect } from 'vitest';
import { kpiSkeletonHtml, kpiJs } from '../src/server/board/kpis.js';
describe('kpiSkeletonHtml', () => {
it('renders the four KPI cards with stable ids', () => {
const h = kpiSkeletonHtml();
for (const id of ['kpiOpen', 'kpiInProgress', 'kpiReview', 'kpiDone']) {
expect(h).toContain(`id="${id}"`);
}
expect(h).toContain('Open');
expect(h).toContain('In Progress');
expect(h).toContain('Review');
expect(h).toContain('Done');
});
});
describe('kpiJs', () => {
it('injects the pure helpers and an updateKpis entry point', () => {
const js = kpiJs();
for (const fn of ['capChips', 'doneStats', 'backlogSeries', 'areaPath', 'laneChips']) {
expect(js).toContain(`function ${fn}`);
}
expect(js).toContain('window.__b2UpdateKpis');
});
it('co-injects the shared constants the helpers depend on', () => {
const js = kpiJs();
expect(js).toContain('DAY_MS');
expect(js).toContain('function dayStart');
});
});

View File

@ -1,45 +0,0 @@
import { describe, it, expect } from 'vitest';
import { sidebarHtml, sidebarJs } from '../src/server/board/sidebar.js';
import { v1BudgetJs } from '../src/server/board/v1Budget.js';
describe('sidebarHtml', () => {
it('renders budget card without über-tabs (tabs live in the donut card)', () => {
const h = sidebarHtml();
expect(h).toContain('id="b2Budget"');
expect(h).not.toContain('data-otab');
expect(h).toContain('id="budgetReset"');
expect(h).toContain('id="b2Feed"');
});
it('keeps the v1 budget mount points for the ported JS', () => {
const h = sidebarHtml();
expect(h).toContain('id="budgetRows"');
expect(h).toContain('id="budgetTotal"');
expect(h).toContain('data-budget-mode="session"');
expect(h).toContain('data-budget-mode="total"');
});
});
describe('sidebarJs', () => {
const js = sidebarJs(v1BudgetJs);
it('renders throughput and feed without über-tab logic', () => {
expect(js).not.toContain('agenthub-budget-otab');
expect(js).toContain('function throughputSeries');
expect(js).toContain('function dayStart');
expect(js).toContain('__b2RenderThroughput');
expect(js).toContain('__b2FeedPush');
});
it('keeps v1 reset behaviour and session/total modes', () => {
expect(js).toContain('writeBaseline');
expect(js).toContain('agenthub-budget-mode');
expect(js).toContain('agenthub-donut-metric');
expect(js).toContain('budgetReset');
expect(js).toContain('renderBudget');
});
it('hosts the three tabs Token/Kosten/Verlauf inside the donut card', () => {
expect(js).toContain('data-donut-tab="tokens"');
expect(js).toContain('data-donut-tab="cost"');
expect(js).toContain('data-donut-tab="verlauf"');
expect(js).toContain('donutTabsHtml');
expect(js).toContain('donutCardShell');
});
});

View File

@ -1,16 +0,0 @@
import { describe, it, expect } from 'vitest';
import { boardV2Css } from '../src/server/board/styles.js';
describe('boardV2Css', () => {
it('contains the glass surface tokens and keyframes', () => {
const css = boardV2Css();
expect(css).toContain('--b2-bg');
expect(css).toContain('rgba(255,255,255,.045)'); // glass surface
expect(css).toContain('@keyframes b2-rise');
expect(css).toContain('@keyframes b2-fill');
expect(css).toContain('@keyframes b2-ping');
});
it('respects prefers-reduced-motion', () => {
expect(boardV2Css()).toContain('@media (prefers-reduced-motion: reduce)');
});
});

View File

@ -1,81 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
capChips, doneStats, backlogSeries, throughputSeries, areaPath,
type KpiTask,
} from '../src/server/board/viewmodel.js';
const day = 24 * 3600 * 1000;
const iso = (msAgo: number) => new Date(Date.now() - msAgo).toISOString();
describe('capChips', () => {
it('shows all chips when at most max', () => {
const chips = [{ name: 'claude', minutes: 5 }, { name: 'codex', minutes: 2 }];
expect(capChips(chips, 3)).toEqual({ visible: chips, hidden: 0 });
});
it('caps at max and reports the hidden count', () => {
const chips = ['a', 'b', 'c', 'd', 'e'].map((name) => ({ name, minutes: 1 }));
const r = capChips(chips, 3);
expect(r.visible.map((c) => c.name)).toEqual(['a', 'b', 'c']);
expect(r.hidden).toBe(2);
});
});
describe('doneStats', () => {
it('computes share and weekly count, excluding cancelled from total', () => {
const tasks: KpiTask[] = [
{ id: '1', status: 'done', updatedAt: iso(2 * day) },
{ id: '2', status: 'done', updatedAt: iso(10 * day) },
{ id: '3', status: 'open' },
{ id: '4', status: 'cancelled' },
];
const r = doneStats(tasks);
expect(r.done).toBe(2);
expect(r.total).toBe(3); // cancelled excluded
expect(r.pct).toBe(67);
expect(r.doneThisWeek).toBe(1);
});
it('handles empty input', () => {
expect(doneStats([])).toEqual({ done: 0, total: 0, pct: 0, doneThisWeek: 0 });
});
});
describe('backlogSeries', () => {
it('returns one value per day, oldest first, ending today', () => {
const tasks: KpiTask[] = [
{ id: '1', status: 'open', createdAt: iso(3 * day) },
{ id: '2', status: 'done', createdAt: iso(13 * day), updatedAt: iso(1 * day) },
];
const s = backlogSeries(tasks, 14);
expect(s).toHaveLength(14);
expect(s[13]).toBe(1); // today: only the open task is backlog
expect(s[0]).toBe(1); // 13 days ago: only task 2 existed and was not done yet
});
});
describe('throughputSeries', () => {
it('counts done tasks per day', () => {
const tasks: KpiTask[] = [
{ id: '1', status: 'done', updatedAt: iso(0) },
{ id: '2', status: 'done', updatedAt: iso(0) },
{ id: '3', status: 'done', updatedAt: iso(5 * day) },
{ id: '4', status: 'open', updatedAt: iso(0) },
];
const s = throughputSeries(tasks, 14);
expect(s).toHaveLength(14);
expect(s[13]).toBe(2); // today
expect(s[8]).toBe(1); // 5 days ago
expect(s.reduce((a, b) => a + b, 0)).toBe(3);
});
});
describe('areaPath', () => {
it('builds line and area paths scaled to width/height', () => {
const { line, area } = areaPath([0, 5, 10], 100, 50);
expect(line).toBe('M0,50 L50,25 L100,0');
expect(area).toBe('M0,50 L50,25 L100,0 L100,50 L0,50 Z');
});
it('flattens when all values are equal (no division by zero)', () => {
const { line } = areaPath([3, 3, 3], 90, 30);
expect(line).toBe('M0,15 L45,15 L90,15');
});
});

View File

@ -1,42 +0,0 @@
import { describe, it, expect } from 'vitest';
import { renderBoardHtml } from '../src/server/board/index.js';
describe('renderBoardHtml (v2)', () => {
const html = renderBoardHtml('demo-project');
it('is a full html document with favicon and splash', () => {
expect(html).toMatch(/^<!doctype html>/i);
expect(html).toContain('rel="icon" href="/logo.svg"');
expect(html).toContain('id="b2-splash"');
});
it('contains header, kpis, columns and sidebar mount points', () => {
for (const s of ['app-header', 'demo-project', 'id="kpiOpen"', 'id="b2Budget"', 'id="b2Feed"']) {
expect(html).toContain(s);
}
});
it('keeps the v1 interaction surface (dnd, sse, modals, budget reset)', () => {
for (const s of ['draggable', "new EventSource('/events')", 'task-log', 'budgetReset', 'taskModal']) {
expect(html).toContain(s);
}
});
it('renders in-progress cards with a progress ring and working glow', () => {
expect(html).toContain('b2-ring');
expect(html).toContain('b2-working');
});
it('gives review cards the same ring design (amber variant)', () => {
expect(html).toContain('b2-ring-review');
});
it('sizes card grid rows to content (overlap regression)', () => {
expect(html).toContain('grid-auto-rows: max-content');
});
it('does not ship the old v1 metric cards or lottie kpi icons', () => {
expect(html).not.toContain('metric-card');
expect(html).not.toContain('lottie');
});
it('runs the init sequence after all module definitions', () => {
const defIdx = html.indexOf('window.__b2UpdateKpis =');
const initIdx = html.indexOf('loadStatusMeta();');
expect(defIdx).toBeGreaterThan(-1);
expect(initIdx).toBeGreaterThan(defIdx);
});
});

View File

@ -1,66 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { createTask, claimTask, reviewTask } from '../src/core/services/taskService.js';
import { searchMemory } from '../src/core/services/memoryService.js';
describe('core correctness (TSK-0007)', () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-core-'));
init(cwd, { projectName: 'core-test', yes: true });
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
// ── FTS5 duplicate-on-update ──────────────────────────────────────────────
it('search returns no duplicates after an entity is updated repeatedly', () => {
const t = createTask(cwd, { title: 'Zephyr indexing widget', role: 'implementer' });
// Each mutation re-upserts into the FTS index.
claimTask(cwd, t.id, 'kimi');
reviewTask(cwd, t.id);
const hits = searchMemory(cwd, 'Zephyr');
const forTask = hits.filter((r) => r.id === t.id);
expect(forTask).toHaveLength(1); // exactly one, not one-per-update
});
// ── claimTask race-guard ──────────────────────────────────────────────────
it('only one of two concurrent claims on the same open task wins', () => {
const t = createTask(cwd, { title: 'contended', role: 'implementer' });
const winners: string[] = [];
let refused = 0;
for (const agent of ['kimi', 'codex']) {
try {
const claimed = claimTask(cwd, t.id, agent);
winners.push(claimed.assignedTo ?? '');
} catch {
refused += 1;
}
}
expect(winners).toHaveLength(1);
expect(refused).toBe(1);
expect(winners[0]).toBe('kimi'); // the first to act wins
});
it('a re-claim by the same agent is an idempotent no-op', () => {
const t = createTask(cwd, { title: 'mine', role: 'implementer' });
claimTask(cwd, t.id, 'kimi');
const again = claimTask(cwd, t.id, 'kimi'); // no throw
expect(again.status).toBe('in_progress');
expect(again.claimedBy).toBe('kimi');
});
it('refuses to claim a task that is not open', () => {
const t = createTask(cwd, { title: 'x', role: 'implementer' });
claimTask(cwd, t.id, 'kimi');
reviewTask(cwd, t.id); // now in review
expect(() => claimTask(cwd, t.id, 'codex')).toThrow(/cannot be claimed/i);
});
});

View File

@ -29,17 +29,4 @@ describe('discovery', () => {
broadcaster.stop(); broadcaster.stop();
} }
}); });
it('broadcasts the latest server URL without restarting the broadcaster', async () => {
const port = 53379;
let url = 'http://127.0.0.1:3377';
const broadcaster = startDiscoveryBroadcaster(() => url, { port, intervalMs: 50 });
try {
expect(await discoverServer(1000, port)).toBe(url);
url = 'http://127.0.0.1:4477';
expect(await discoverServer(1000, port)).toBe(url);
} finally {
broadcaster.stop();
}
});
}); });

View File

@ -1,43 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { startMdnsAdvertise } from '../src/server/mdns.js';
import type { Bonjour, Service } from 'bonjour-service';
describe('mDNS advertise', () => {
it('republishes after an IP change and stops the old service first', async () => {
let ip = '192.168.1.10';
const calls: string[] = [];
const published: Array<{ txt?: Record<string, string> }> = [];
const createBonjour = () =>
({
publish(opts: { txt?: Record<string, string> }) {
calls.push(`publish:${opts.txt?.address ?? ''}`);
published.push(opts);
return {
on: vi.fn(),
stop: vi.fn(() => calls.push('stop')),
} as unknown as Service;
},
destroy: vi.fn(() => calls.push('destroy')),
}) as unknown as Bonjour;
const handle = startMdnsAdvertise({
port: 3377,
pollMs: 25,
getIp: () => ip,
createBonjour,
});
expect(handle).toBeDefined();
expect(published[0]?.txt?.address).toBe('192.168.1.10');
ip = '192.168.1.44';
await new Promise((resolve) => setTimeout(resolve, 70));
expect(published).toHaveLength(2);
expect(published[1]?.txt?.address).toBe('192.168.1.44');
expect(calls).toEqual(['publish:192.168.1.10', 'stop', 'destroy', 'publish:192.168.1.44']);
handle?.stop();
});
});

View File

@ -1,39 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { messageRead, inboxMarkRead } from '../src/cli/commands/message.js';
import { createMessage, listInbox } from '../src/core/services/messageService.js';
describe('message commands', () => {
let cwd: string;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-msg-cmd-'));
await init(cwd, { yes: true, projectName: 'test' });
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('messageRead marks one message as read', () => {
const msg = createMessage(cwd, { from: 'codex', to: 'claude', text: 'done' });
messageRead(cwd, msg.id);
expect(listInbox(cwd, 'claude', { unreadOnly: true })).toHaveLength(0);
expect(listInbox(cwd, 'claude')).toMatchObject([{ id: msg.id, status: 'read' }]);
});
it('inboxMarkRead bulk-marks unread alias messages for architect', () => {
createMessage(cwd, { from: 'windows-claude', to: 'architect', text: 'ping 1' });
createMessage(cwd, { from: 'codex', to: 'claude', text: 'ping 2' });
expect(listInbox(cwd, 'architect', { unreadOnly: true })).toHaveLength(2);
inboxMarkRead(cwd, { agent: 'architect', unreadOnly: true });
expect(listInbox(cwd, 'architect', { unreadOnly: true })).toHaveLength(0);
expect(listInbox(cwd, 'claude')).toHaveLength(2);
});
});

View File

@ -1,72 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import {
createMessage,
listInbox,
getMessage,
markMessageDelivered,
markMessageRead,
ackMessage,
} from '../src/core/services/messageService.js';
import { messageReply } from '../src/cli/commands/message.js';
describe('message read-receipts + replies', () => {
let cwd: string;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-msg-receipt-'));
await init(cwd, { yes: true, projectName: 'test' });
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('listInbox transitions unread → delivered on fetch (agent-scoped)', () => {
const msg = createMessage(cwd, { from: 'codex', to: 'claude', text: 'hi' });
expect(getMessage(cwd, msg.id).message.status).toBe('unread');
const inbox = listInbox(cwd, 'claude');
expect(inbox[0].status).toBe('delivered');
// Persisted on disk, not just in the returned row.
expect(getMessage(cwd, msg.id).message.status).toBe('delivered');
});
it('markMessageDelivered never downgrades a stronger receipt', () => {
const msg = createMessage(cwd, { from: 'codex', to: 'claude', text: 'hi' });
markMessageRead(cwd, msg.id);
const after = markMessageDelivered(cwd, msg.id);
expect(after.status).toBe('read');
});
it('ackMessage transitions to acked (strongest receipt)', () => {
const msg = createMessage(cwd, { from: 'codex', to: 'claude', text: 'hi' });
const acked = ackMessage(cwd, msg.id, 'claude');
expect(acked.status).toBe('acked');
expect(getMessage(cwd, msg.id).message.status).toBe('acked');
});
it('persists replyTo through create → getMessage', () => {
const parent = createMessage(cwd, { from: 'claude', to: 'codex', text: 'do X' });
const reply = createMessage(cwd, { from: 'codex', to: 'claude', text: 'done', replyTo: parent.id });
expect(reply.replyTo).toBe(parent.id);
expect(getMessage(cwd, reply.id).message.replyTo).toBe(parent.id);
});
it('messageReply routes back to the parent sender and inherits its task', () => {
const parent = createMessage(cwd, { from: 'claude', to: 'codex', text: 'do X', taskId: 'TSK-0007' });
const reply = messageReply(cwd, parent.id, { from: 'codex', text: 'on it' });
expect(reply.to).toBe('claude'); // back to parent.from
expect(reply.replyTo).toBe(parent.id);
expect(reply.taskId).toBe('TSK-0007'); // inherited
});
it('messageReply --task overrides the inherited task', () => {
const parent = createMessage(cwd, { from: 'claude', to: 'codex', text: 'do X', taskId: 'TSK-0007' });
const reply = messageReply(cwd, parent.id, { from: 'codex', text: 'on it', taskId: 'TSK-0009' });
expect(reply.taskId).toBe('TSK-0009');
});
});

View File

@ -33,7 +33,6 @@ describe('server routes', () => {
it('lists tasks via GET /tasks', async () => { it('lists tasks via GET /tasks', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'GET', url: '/tasks' }); const res = await app.inject({ method: 'GET', url: '/tasks' });
expect(res.headers['cache-control']).toContain('no-store');
expect(JSON.parse(res.payload)).toHaveLength(1); expect(JSON.parse(res.payload)).toHaveLength(1);
}); });
@ -48,18 +47,10 @@ describe('server routes', () => {
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);
}); });
it('claims manually when setting in_progress without assignedTo', async () => { it('returns 400 when claiming without assignedTo', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } }); const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
expect(JSON.parse(res.payload)).toMatchObject({ status: 'in_progress', assignedTo: 'manual', claimedBy: 'manual' });
});
it('claims with the existing assignee when setting in_progress without assignedTo', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload)).toMatchObject({ status: 'in_progress', assignedTo: 'codex', claimedBy: 'codex' });
}); });
it('PATCH /tasks/:id → review', async () => { it('PATCH /tasks/:id → review', async () => {
@ -69,18 +60,6 @@ describe('server routes', () => {
expect(JSON.parse(res.payload).status).toBe('review'); expect(JSON.parse(res.payload).status).toBe('review');
}); });
it('a second claim of the same task returns a clean 400 (race guard, board does not 500) — TSK-0007', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const first = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'kimi' } });
expect(first.statusCode).toBe(200);
// Another agent tries to claim the now in-progress task.
const second = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
expect(second.statusCode).toBe(400);
// The original claim is intact — not clobbered.
const still = JSON.parse((await app.inject({ method: 'GET', url: '/tasks/TSK-0001' })).payload) as { task: { claimedBy?: string } };
expect(still.task.claimedBy).toBe('kimi');
});
it('PATCH /tasks/:id → review accepts reviewer separately from assignedTo', async () => { it('PATCH /tasks/:id → review accepts reviewer separately from assignedTo', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review', reviewer: 'claude' } }); const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review', reviewer: 'claude' } });
@ -143,17 +122,13 @@ describe('server routes', () => {
expect(res.headers['content-type']).toContain('text/html'); expect(res.headers['content-type']).toContain('text/html');
const html = res.payload; const html = res.payload;
expect(html).toContain('<title>agenthub — Board</title>'); expect(html).toContain('<title>AgentHub Board</title>');
// The three active lanes are present in the static markup (board v2). // All five status columns are present in the static markup.
for (const col of ['open', 'in_progress', 'review']) { for (const col of ['open', 'in_progress', 'review', 'done', 'cancelled']) {
expect(html).toContain(`data-column="${col}"`); expect(html).toContain(`data-column="${col}"`);
} }
// Board polling stays wired; handoffs/decisions now live on dedicated pages. // Board polling stays wired; handoffs/decisions now live on dedicated pages.
expect(html).toContain("getJSON('/tasks')"); expect(html).toContain("getJSON('/tasks')");
expect(html).toContain("cache: 'no-store'");
expect(html).toContain("window.addEventListener('pageshow'");
expect(html).toContain("document.addEventListener('visibilitychange'");
expect(html).toContain("var agent = assigned || titledAgent || 'manual'");
expect(html).toContain('setInterval(refresh'); expect(html).toContain('setInterval(refresh');
expect(html).toContain('Token Insights'); expect(html).toContain('Token Insights');
expect(html).toContain('data-budget-mode="session"'); expect(html).toContain('data-budget-mode="session"');
@ -267,7 +242,7 @@ describe('server routes', () => {
expect(res.payload).toContain('1m'); expect(res.payload).toContain('1m');
}); });
it('serves the activity page with tasks only — messaging split out to /messages', async () => { it('serves the activity page with recent activity and done archive', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Done item', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Done item', role: 'implementer' } });
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
@ -285,43 +260,8 @@ describe('server routes', () => {
expect(res.headers['content-type']).toContain('text/html'); expect(res.headers['content-type']).toContain('text/html');
expect(res.payload).toContain('Recent Activity'); expect(res.payload).toContain('Recent Activity');
expect(res.payload).toContain('Done Archive'); expect(res.payload).toContain('Done Archive');
expect(res.payload).toContain('TSK-0001');
// Hard separation: no message rows leak into /activity anymore.
expect(res.payload).not.toContain('MSG-0001');
expect(res.payload).not.toContain('kind-message');
});
it('serves the /messages conversation view (HTML) with a conversation list', async () => {
await app.inject({ method: 'POST', url: '/messages', payload: { from: 'claude', to: 'codex', text: 'Please check the board' } });
const res = await app.inject({ method: 'GET', url: '/messages', headers: { accept: 'text/html' } });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
expect(res.payload).toContain('Conversations');
expect(res.payload).toContain('MSG-0001'); expect(res.payload).toContain('MSG-0001');
expect(res.payload).toContain('Please check the board'); expect(res.payload).toContain('TSK-0001');
// JSON contract unchanged: no accept header → JSON list, not HTML.
const json = await app.inject({ method: 'GET', url: '/messages' });
expect(() => JSON.parse(json.payload)).not.toThrow();
});
it('read-receipt chain: create → inbox(delivered) → read → ack', async () => {
await app.inject({ method: 'POST', url: '/messages', payload: { from: 'codex', to: 'claude', text: 'ping' } });
// create → unread
const listed = JSON.parse((await app.inject({ method: 'GET', url: '/messages' })).payload) as Array<{ id: string; status: string }>;
expect(listed[0].status).toBe('unread');
// inbox fetch → delivered (agent-scoped side effect)
const inbox = JSON.parse((await app.inject({ method: 'GET', url: '/messages?agent=claude' })).payload) as Array<{ id: string; status: string }>;
expect(inbox[0].status).toBe('delivered');
// read
const read = JSON.parse((await app.inject({ method: 'POST', url: '/messages/MSG-0001/read' })).payload) as { status: string };
expect(read.status).toBe('read');
// ack
const acked = JSON.parse((await app.inject({ method: 'POST', url: '/messages/MSG-0001/ack', payload: { by: 'claude' } })).payload) as { status: string };
expect(acked.status).toBe('acked');
}); });
it('GET /handoffs returns fromRole and toRole fields', async () => { it('GET /handoffs returns fromRole and toRole fields', async () => {

View File

@ -48,27 +48,10 @@ describe('single source of truth', () => {
const port = Number(new URL(server.url).port); const port = Number(new URL(server.url).port);
const logs: string[] = []; const logs: string[] = [];
const original = console.log; const original = console.log;
console.log = (msg: unknown) => logs.push(String(msg)); console.log = (msg: string) => logs.push(msg);
try { await serverStart(cwd, { host: '127.0.0.1', port });
await serverStart(cwd, { host: '127.0.0.1', port }); console.log = original;
} finally {
console.log = original;
}
expect(logs.some((m) => m.includes('already running'))).toBe(true); expect(logs.some((m) => m.includes('already running'))).toBe(true);
}); });
it('does not start LAN advertisers during tests', async () => {
const logs: string[] = [];
const original = console.log;
console.log = (msg: unknown) => logs.push(String(msg));
let isolated: Awaited<ReturnType<typeof startServer>> | undefined;
try {
isolated = await startServer(cwd, { host: '127.0.0.1', port: 0 });
} finally {
console.log = original;
}
await isolated?.app.close();
expect(logs.some((m) => m.includes('reachable in a browser'))).toBe(false);
});
}); });
}); });

View File

@ -637,43 +637,3 @@ describe('watch --await-review', () => {
await expect(watching).resolves.toBeUndefined(); await expect(watching).resolves.toBeUndefined();
}, 5000); }, 5000);
}); });
// ─── 9. watch --await-message: architect inbox notifier ─────────────────────
describe('watch --await-message', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-await-message-'));
init(cwd, { projectName: 'await-message', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
await server.app.close();
rmSync(cwd, { recursive: true, force: true });
});
it('returns immediately when an unread alias message already exists', async () => {
await fetch(`${server.url}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'windows-claude', to: 'architect', text: 'need review' }),
});
await expect(watchEvents(server.url, { awaitMessage: 'claude' })).resolves.toBeUndefined();
}, 4000);
it('exits when a new message arrives for the watched alias', async () => {
const watching = watchEvents(server.url, { awaitMessage: 'architect', newOnly: true });
await new Promise((r) => setTimeout(r, 200));
await fetch(`${server.url}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'codex', to: 'claude', text: 'ready' }),
});
await expect(watching).resolves.toBeUndefined();
}, 5000);
});

View File

@ -1,105 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { buildApp } from '../src/server/index.js';
import { init } from '../src/cli/commands/init.js';
import { eventBus } from '../src/server/events.js';
import { readTaskLog } from '../src/core/services/taskLogService.js';
import { remoteClient } from '../src/cli/remoteClient.js';
describe('task live console (TSK-0074)', () => {
let cwd: string;
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-tasklog-'));
init(cwd, { projectName: 'tasklog-test', yes: true });
app = buildApp(cwd);
});
afterEach(() => {
eventBus.removeAllListeners('log');
eventBus.removeAllListeners('change');
rmSync(cwd, { recursive: true, force: true });
});
it('PATCH in_progress appends a status log line + emits exactly one task-log event (no double change)', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
const logs: Array<{ taskId?: string; text?: string; level?: string }> = [];
const changes: unknown[] = [];
const onLog = (p: { taskId?: string; text?: string; level?: string }) => logs.push(p);
const onChange = (e: unknown) => changes.push(e);
eventBus.on('log', onLog);
eventBus.on('change', onChange);
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
eventBus.off('log', onLog);
eventBus.off('change', onChange);
// Exactly one task-log SSE event, carrying the task id + a "Claimed by" line.
expect(logs).toHaveLength(1);
expect(logs[0].taskId).toBe('TSK-0001');
expect(logs[0].text).toContain('Claimed by codex');
expect(logs[0].level).toBe('status');
// No double-emission on the change channel (one emitChange per PATCH).
expect(changes).toHaveLength(1);
// Persisted to the task's .log file.
const persisted = readTaskLog(cwd, 'TSK-0001');
expect(persisted.some((e) => e.text.includes('Claimed by codex') && e.level === 'status')).toBe(true);
});
it('logs every status transition (review, done)', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } });
const texts = readTaskLog(cwd, 'TSK-0001').map((e) => e.text);
expect(texts.some((t) => t.startsWith('Claimed by'))).toBe(true);
expect(texts.some((t) => t.startsWith('Submitted for review'))).toBe(true);
expect(texts.some((t) => t.startsWith('Approved — done'))).toBe(true);
});
it('task detail HTML renders the Live Console with historic lines', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'wrote failing test', agent: 'codex', level: 'info' } });
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001', headers: { accept: 'text/html' } });
expect(res.statusCode).toBe(200);
expect(res.payload).toContain('Live Console');
expect(res.payload).toContain('wrote failing test');
// Live tail wires to the named task-log SSE event, scoped to this task id.
expect(res.payload).toContain("addEventListener('task-log'");
expect(res.payload).toContain('"TSK-0001"');
});
it('POST /tasks/:id/log returns the stored entry', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'green: 12 tests', level: 'status' } });
expect(res.statusCode).toBe(200);
const entry = JSON.parse(res.payload) as { text: string; level: string; ts: string };
expect(entry.text).toBe('green: 12 tests');
expect(entry.level).toBe('status');
expect(entry.ts).toBeTruthy();
});
it('remoteClient.appendTaskLog POSTs to /tasks/:id/log', async () => {
vi.restoreAllMocks();
const stored = { ts: new Date().toISOString(), text: 'progress', level: 'info' };
globalThis.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify(stored)),
} as Response);
const res = await remoteClient.appendTaskLog('http://localhost:3377', 'TSK-0001', { text: 'progress', agent: 'codex' });
expect(res.text).toBe('progress');
const call = vi.mocked(fetch).mock.calls[0];
expect(String(call[0])).toBe('http://localhost:3377/tasks/TSK-0001/log');
expect((call[1] as RequestInit).method).toBe('POST');
});
});

View File

@ -37,8 +37,6 @@ describe('taskService', () => {
const claimed = claimTask(cwd, task.id, 'codex'); const claimed = claimTask(cwd, task.id, 'codex');
expect(claimed.status).toBe('in_progress'); expect(claimed.status).toBe('in_progress');
expect(claimed.assignedTo).toBe('codex'); expect(claimed.assignedTo).toBe('codex');
expect(claimed.claimedBy).toBe('codex');
expect(listTasks(cwd, { status: 'in_progress' })[0]).toMatchObject({ assignedTo: 'codex', claimedBy: 'codex' });
const done = doneTask(cwd, task.id); const done = doneTask(cwd, task.id);
expect(done.status).toBe('done'); expect(done.status).toBe('done');
}); });

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { agentAvatar, designTokensCss, escapeHtml, liveTimerJs, statusPill, appHeader, appHeaderCss, appHeaderOnlyCss } from '../src/server/ui-shared.js'; import { agentAvatar, designTokensCss, escapeHtml, liveTimerJs, statusPill } from '../src/server/ui-shared.js';
describe('ui-shared helpers', () => { describe('ui-shared helpers', () => {
it('designTokensCss returns the dark palette variables', () => { it('designTokensCss returns the dark palette variables', () => {
@ -48,32 +48,3 @@ describe('ui-shared helpers', () => {
expect(js).toContain('claimed'); expect(js).toContain('claimed');
}); });
}); });
describe('appHeader (v2 shared header)', () => {
it('renders logo, project cell, nav links and status mounts', () => {
const h = appHeader('demo', 'team');
expect(h).toContain('/logo.svg');
expect(h).toContain('id="projectName"');
expect(h).toContain('b2-proj');
expect(h).toContain('id="newTaskBtn"');
expect(h).toContain('id="sseStatus"');
for (const path of ['/board', '/team', '/activity', '/messages', '/decisions']) {
expect(h).toContain(`href="${path}"`);
}
});
it('marks the current page active with aria-current', () => {
const team = appHeader('demo', 'team');
expect(team).toContain('active" href="/team" aria-current="page"');
const board = appHeader('demo', 'board');
expect(board).toContain('active" href="/board" aria-current="page"');
expect(board).not.toContain('active" href="/team"');
});
it('escapes the project name', () => {
expect(appHeader('<b>x</b>', 'board')).not.toContain('<b>x</b>');
});
it('appHeaderOnlyCss is a prefix of appHeaderCss', () => {
expect(appHeaderCss().startsWith(appHeaderOnlyCss())).toBe(true);
expect(appHeaderOnlyCss()).toContain('.app-header');
expect(appHeaderCss()).toContain('.modal-backdrop');
});
});

View File

@ -39,7 +39,6 @@ describe('agenthub work — immediate claim (local)', () => {
describe('agenthub work — wait then auto-claim (server SSE)', () => { describe('agenthub work — wait then auto-claim (server SSE)', () => {
let cwd: string; let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>; let server: Awaited<ReturnType<typeof startServer>>;
let nextServer: Awaited<ReturnType<typeof startServer>> | undefined;
beforeEach(async () => { beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-work-wait-')); cwd = mkdtempSync(join(tmpdir(), 'ah-work-wait-'));
@ -48,8 +47,7 @@ describe('agenthub work — wait then auto-claim (server SSE)', () => {
}); });
afterEach(async () => { afterEach(async () => {
await server.app.close().catch(() => undefined); await server.app.close();
await nextServer?.app.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}); });
@ -79,105 +77,4 @@ describe('agenthub work — wait then auto-claim (server SSE)', () => {
expect(mine?.status).toBe('in_progress'); expect(mine?.status).toBe('in_progress');
expect(mine?.assignedTo).toBe('kimi'); expect(mine?.assignedTo).toBe('kimi');
}, 6000); }, 6000);
it('reconnects after the SSE server moves and claims a later task', async () => {
const workDone = workAgent({
serverUrl: server.url,
projectCwd: cwd,
agent: 'kimi',
role: 'implementer',
timeoutSec: 6,
reconnectBackoffMs: [50, 100],
discoverServer: async () => nextServer?.url,
});
await new Promise((r) => setTimeout(r, 200));
server.app.server.closeAllConnections?.();
await server.app.close();
nextServer = await startServer(cwd, { host: '127.0.0.1', port: 0 });
await fetch(`${nextServer.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'kimi: delegated after reconnect', role: 'implementer' }),
});
await workDone;
const tasks = (await fetch(`${nextServer.url}/tasks`).then((r) => r.json())) as Task[];
const mine = tasks.find((t) => (t.title ?? '').startsWith('kimi: delegated after reconnect'));
expect(mine).toBeDefined();
expect(mine?.status).toBe('in_progress');
expect(mine?.assignedTo).toBe('kimi');
}, 8000);
// ── TSK-0119: after a review submit the re-armed work loop must stay reachable ──
it('wakes on an architect follow-up message instead of going dormant', async () => {
// The implementer re-armed `work` after submitting; no task is addressed yet.
const workDone = workAgent({
serverUrl: server.url,
projectCwd: cwd,
agent: 'kimi',
role: 'implementer',
timeoutSec: 4,
});
await new Promise((r) => setTimeout(r, 200));
await fetch(`${server.url}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'claude', to: 'kimi', text: 'quick question about your submission' }),
});
await workDone; // resolves because the message woke the loop (not on timeout)
// The loop drained + marked the message read — proving it woke on the message,
// not that it merely timed out (a timeout would leave it delivered/unread).
const inbox = (await fetch(`${server.url}/messages?agent=kimi`).then((r) => r.json())) as Array<{ text: string; status: string }>;
const m = inbox.find((x) => x.text.includes('quick question'));
expect(m).toBeDefined();
expect(m?.status).toBe('read');
}, 7000);
it('wakes and re-claims when a submitted task is reopened', async () => {
// Seed a task addressed to kimi, claim it, submit for review.
await fetch(`${server.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'kimi: build the widget', role: 'implementer' }),
});
await fetch(`${server.url}/tasks/TSK-0001`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'in_progress', assignedTo: 'kimi' }),
});
await fetch(`${server.url}/tasks/TSK-0001`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'review' }),
});
// Re-armed work loop: the task is in review, so nothing is addressed/open yet.
const workDone = workAgent({
serverUrl: server.url,
projectCwd: cwd,
agent: 'kimi',
role: 'implementer',
timeoutSec: 4,
});
await new Promise((r) => setTimeout(r, 200));
// Architect reopens (send back to implementer).
await fetch(`${server.url}/tasks/TSK-0001`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'open' }),
});
await workDone;
const { task } = getTask(cwd, 'TSK-0001');
expect(task.status).toBe('in_progress');
expect(task.assignedTo).toBe('kimi');
}, 7000);
}); });