feat(board+team): interactive board, animated team tree, token budget, mDNS, realtime architect review + codex/kimi autostart

Board (/board):
- Drag an AGENT chip onto a task card to (re)assign it (realtime-notified).
- Drag a task card to a column to change status; open→in_progress auto-assigns
  from the title's "<agent>:" prefix — no manual agent picking.
- "+ New task" composer (agent dropdown removed; agent comes from the title).
- Live Cost & Budget panel.

Team (/team): live SSE sync of busy state + always-on ambient animation
(connector shimmer, idle glow) that brightens to a busy pulse when an agent
works. Org-chart hierarchy stays.

Token accounting: new budgetService/rosterService + GET /budget and /agents.
Real doneTokens + time-on-task estimate capped at 45 min/task (avoids the
wall-clock overcount that produced multi-million-token totals), blended
per-model EUR cost + optional budget bars. All estimates flagged "~".

Autostart: `agent setup` writes deterministic SessionStart hooks for Codex
(~/.codex/config.toml) and Kimi (~/.kimi-code/config.toml), not just Claude
Code. Verified: both auto-enter the agenthub_work loop.

Realtime architect review: agenthub_work is role-aware — architect/reviewer
blocks on SSE and wakes when a task hits review (no manual watcher re-arm).

mDNS: server advertises agenthub.local (bonjour-service) so the hub is
reachable in a browser on the LAN without an IP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-08 03:23:48 +02:00
parent 1e29a7b9b9
commit 7ca6c859f2
16 changed files with 1196 additions and 46 deletions

View File

@ -25,6 +25,7 @@
"@inquirer/prompts": "^7.0.0", "@inquirer/prompts": "^7.0.0",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"better-sqlite3": "^12.0.0", "better-sqlite3": "^12.0.0",
"bonjour-service": "^1.4.2",
"commander": "^13.0.0", "commander": "^13.0.0",
"fastify": "^5.0.0", "fastify": "^5.0.0",
"yaml": "^2.6.0", "yaml": "^2.6.0",

38
pnpm-lock.yaml generated
View File

@ -17,6 +17,9 @@ importers:
better-sqlite3: better-sqlite3:
specifier: ^12.0.0 specifier: ^12.0.0
version: 12.11.1 version: 12.11.1
bonjour-service:
specifier: ^1.4.2
version: 1.4.2
commander: commander:
specifier: ^13.0.0 specifier: ^13.0.0
version: 13.1.0 version: 13.1.0
@ -362,6 +365,9 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5': '@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@leichtgewicht/ip-codec@2.0.5':
resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==}
'@modelcontextprotocol/sdk@1.29.0': '@modelcontextprotocol/sdk@1.29.0':
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -598,6 +604,9 @@ packages:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
engines: {node: '>=18'} engines: {node: '>=18'}
bonjour-service@1.4.2:
resolution: {integrity: sha512-lMskhnsW70yWHr4PhPeh2rvaIkLSaDpp+nmtbXBZaNKTXwxL73QOkW6HhbzqTImXjevn9TreGT4GACGBCGP9nQ==}
buffer@5.7.1: buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
@ -711,6 +720,10 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
dns-packet@5.6.1:
resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==}
engines: {node: '>=6'}
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -967,6 +980,10 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
multicast-dns@7.2.5:
resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==}
hasBin: true
mute-stream@2.0.0: mute-stream@2.0.0:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0} engines: {node: ^18.17.0 || >=20.5.0}
@ -1245,6 +1262,9 @@ packages:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
thunky@1.1.0:
resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==}
tinybench@2.9.0: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@ -1642,6 +1662,8 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/sourcemap-codec@1.5.5': {}
'@leichtgewicht/ip-codec@2.0.5': {}
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
dependencies: dependencies:
'@hono/node-server': 1.19.14(hono@4.12.27) '@hono/node-server': 1.19.14(hono@4.12.27)
@ -1864,6 +1886,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
bonjour-service@1.4.2:
dependencies:
fast-deep-equal: 3.1.3
multicast-dns: 7.2.5
buffer@5.7.1: buffer@5.7.1:
dependencies: dependencies:
base64-js: 1.5.1 base64-js: 1.5.1
@ -1948,6 +1975,10 @@ snapshots:
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
dns-packet@5.6.1:
dependencies:
'@leichtgewicht/ip-codec': 2.0.5
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@ -2238,6 +2269,11 @@ snapshots:
ms@2.1.3: {} ms@2.1.3: {}
multicast-dns@7.2.5:
dependencies:
dns-packet: 5.6.1
thunky: 1.1.0
mute-stream@2.0.0: {} mute-stream@2.0.0: {}
nanoid@3.3.15: {} nanoid@3.3.15: {}
@ -2559,6 +2595,8 @@ snapshots:
dependencies: dependencies:
real-require: 1.0.0 real-require: 1.0.0
thunky@1.1.0: {}
tinybench@2.9.0: {} tinybench@2.9.0: {}
tinyexec@0.3.2: {} tinyexec@0.3.2: {}

View File

@ -1,23 +1,39 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path'; import { join, dirname } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { findProjectRoot } from '../../core/paths.js'; import { findProjectRoot } from '../../core/paths.js';
/** /**
* Auto-start: make a Claude Code agent enter the agenthub work-loop the moment a * Auto-start: make an agent enter the agenthub work-loop the moment its CLI
* session starts no manual "call agenthub_work" needed. * session starts no manual "call agenthub_work" needed.
* *
* Mechanism: a verified Claude Code `SessionStart` hook (.claude/settings.json) * Every supported CLI exposes a `SessionStart` lifecycle hook whose command runs
* whose command runs `agenthub hook-context`, which prints the work-loop * `agenthub hook-context`, which prints the work-loop instruction. That text is
* instruction. That text is injected into the new session as context. * injected into the new session as context. We write the hook to the RIGHT place
* per CLI:
* - Claude Code `.claude/settings.json` (project, or ~/ with --user)
* - Codex `~/.codex/config.toml` (global; TUI SessionStart hook)
* - Kimi Code `~/.kimi-code/config.toml` (global; SessionStart hook)
* *
* Honest limit (per the Claude Code docs): SessionStart context is a strong * Honest limits: SessionStart context is a strong NUDGE the model reads, not a
* NUDGE the model reads, not a hard directive it must obey so the guide * hard directive it must obey. Codex fires the hook in interactive TUI sessions
* reinforces it. It also can't block startup. Codex/Kimi aren't Claude Code; * from the global config; Kimi fires the command on SessionStart but its docs
* their guide (CODEX.md / KIMI.md) carries the same "call agenthub_work first" * don't confirm stdout is injected as context so for Kimi the guide (KIMI.md)
* nudge. * still reinforces it. The old behaviour (Claude-only, codex/kimi guide-text) is
* replaced by real hooks for all three.
*/ */
export type AgentCli = 'claude' | 'codex' | 'kimi';
/** Infer the CLI from the agent name unless one was given explicitly. */
export function inferCli(agent: string, explicit?: string): AgentCli {
if (explicit === 'claude' || explicit === 'codex' || explicit === 'kimi') return explicit;
const a = agent.toLowerCase();
if (a.includes('codex')) return 'codex';
if (a.includes('kimi')) return 'kimi';
return 'claude';
}
/** Printed by the SessionStart hook → injected as session context. */ /** Printed by the SessionStart hook → injected as session context. */
export function hookContext(opts: { agent: string; role: string }): void { export function hookContext(opts: { agent: string; role: string }): void {
const { agent, role } = opts; const { agent, role } = opts;
@ -33,7 +49,36 @@ export function hookContext(opts: { agent: string; role: string }): void {
); );
} }
export function agentSetup(cwd: string, opts: { agent: string; role: string; user?: boolean }): void { // ─── Marker-delimited upsert (for line-based TOML configs) ───────────────────
// Codex/Kimi configs are TOML; we don't ship a TOML parser. Instead we fence our
// managed hook in marker comments and upsert idempotently: strip any prior block,
// append the fresh one. A `[[hooks…]]` header at EOF starts its own table-array
// element, so appending is always valid TOML.
const MARK_BEGIN = '# >>> agenthub autostart (managed — do not edit inside) >>>';
const MARK_END = '# <<< agenthub autostart (managed) <<<';
function upsertMarkerBlock(path: string, block: string): void {
let existing = '';
if (existsSync(path)) existing = readFileSync(path, 'utf-8');
const begin = existing.indexOf(MARK_BEGIN);
if (begin !== -1) {
const endIdx = existing.indexOf(MARK_END, begin);
const end = endIdx !== -1 ? endIdx + MARK_END.length : existing.length;
// Drop the old block plus one trailing newline if present.
existing = existing.slice(0, begin) + existing.slice(end).replace(/^\n/, '');
}
existing = existing.replace(/\s*$/, '');
const managed = `${MARK_BEGIN}\n${block}\n${MARK_END}\n`;
const next = existing.length ? `${existing}\n\n${managed}` : managed;
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, next, 'utf-8');
}
// ─── Per-CLI writers ─────────────────────────────────────────────────────────
function setupClaude(cwd: string, opts: { agent: string; role: string; user?: boolean }, out: (s: string) => void): void {
const base = opts.user ? homedir() : (findProjectRoot(cwd) ?? cwd); const base = opts.user ? homedir() : (findProjectRoot(cwd) ?? cwd);
const dir = join(base, '.claude'); const dir = join(base, '.claude');
const settingsPath = join(dir, 'settings.json'); const settingsPath = join(dir, 'settings.json');
@ -60,9 +105,50 @@ export function agentSetup(cwd: string, opts: { agent: string; role: string; use
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
const out = (s: string) => process.stdout.write(s + '\n'); out(`AgentHub: Claude Code auto-start hook written -> ${settingsPath}`);
out(`AgentHub: auto-start hook written -> ${settingsPath}`);
out(` New Claude Code sessions ${opts.user ? '(all projects)' : 'in this project'} start as "${opts.agent}" (${opts.role}) and enter the work loop.`); out(` New Claude Code sessions ${opts.user ? '(all projects)' : 'in this project'} start as "${opts.agent}" (${opts.role}) and enter the work loop.`);
out(' Note: SessionStart context is a strong nudge, not a hard guarantee — the guide reinforces it.'); out(' Note: SessionStart context is a strong nudge, not a hard guarantee.');
out(' Codex/Kimi are not Claude Code: their guide already nudges agenthub_work on first turn.'); }
function setupCodex(opts: { agent: string; role: string }, out: (s: string) => void): void {
const path = join(homedir(), '.codex', 'config.toml');
const command = `agenthub hook-context --agent ${opts.agent} --role ${opts.role}`;
const block =
`[[hooks.SessionStart]]\n` +
`matcher = "startup|resume"\n\n` +
`[[hooks.SessionStart.hooks]]\n` +
`type = "command"\n` +
`command = ${JSON.stringify(command)}\n` +
`timeout = 30\n` +
`statusMessage = "AgentHub auto-start (${opts.agent})"`;
upsertMarkerBlock(path, block);
out(`AgentHub: Codex auto-start hook written -> ${path}`);
out(` New Codex TUI sessions start as "${opts.agent}" (${opts.role}) and enter the work loop.`);
out(' Codex fires SessionStart hooks from the global config in interactive sessions.');
}
function setupKimi(opts: { agent: string; role: string }, out: (s: string) => void): void {
const home = process.env.KIMI_CODE_HOME || join(homedir(), '.kimi-code');
const path = join(home, 'config.toml');
const command = `agenthub hook-context --agent ${opts.agent} --role ${opts.role}`;
const block =
`[[hooks]]\n` +
`event = "SessionStart"\n` +
`command = ${JSON.stringify(command)}\n` +
`timeout = 30`;
upsertMarkerBlock(path, block);
out(`AgentHub: Kimi auto-start hook written -> ${path}`);
out(` New Kimi Code sessions run the work-loop nudge on SessionStart as "${opts.agent}" (${opts.role}).`);
out(' Note: Kimi fires the hook command; if it does not inject stdout as context, KIMI.md still nudges agenthub_work.');
}
export function agentSetup(
cwd: string,
opts: { agent: string; role: string; user?: boolean; cli?: string },
): void {
const out = (s: string) => process.stdout.write(s + '\n');
const cli = inferCli(opts.agent, opts.cli);
if (cli === 'codex') setupCodex(opts, out);
else if (cli === 'kimi') setupKimi(opts, out);
else setupClaude(cwd, opts, out);
} }

View File

@ -41,6 +41,23 @@ async function listOpenRoleTasks(ctx: AgentContext): Promise<Listed[]> {
: svcListTasks(ctx.projectCwd, { role: ctx.role, status: 'open' }); : svcListTasks(ctx.projectCwd, { role: ctx.role, status: 'open' });
} }
/** True for roles that review submitted work rather than implement it. */
export function isReviewerRole(role: string): boolean {
const r = role.toLowerCase();
return r === 'architect' || r === 'reviewer';
}
/**
* Tasks awaiting review the architect's equivalent of "addressed open tasks".
* These are what an architect's `agenthub_work` loop should wake on, so review
* submissions reach the architect in realtime instead of needing a manual re-arm.
*/
export async function listReviewTasks(ctx: AgentContext): Promise<Listed[]> {
return ctx.serverUrl
? await remoteClient.listTasks(ctx.serverUrl, { status: 'review' })
: svcListTasks(ctx.projectCwd, { status: 'review' });
}
/** /**
* Find the open task addressed to this agent. "Addressed" = task title starts * Find the open task addressed to this agent. "Addressed" = task title starts
* with "<agent>:" (delegation convention), OR a handoff for the task has * with "<agent>:" (delegation convention), OR a handoff for the task has

View File

@ -498,12 +498,13 @@ export function createProgram(cwd: string): Command {
const agentCmd = new Command('agent').description('Per-agent machine setup'); const agentCmd = new Command('agent').description('Per-agent machine setup');
agentCmd agentCmd
.command('setup') .command('setup')
.description('Auto-start: write a Claude Code SessionStart hook so sessions enter the work loop') .description('Auto-start: write a SessionStart hook (Claude Code / Codex / Kimi) so sessions enter the work loop')
.requiredOption('--agent <name>', 'This machine\'s agent name') .requiredOption('--agent <name>', 'This machine\'s agent name')
.requiredOption('--role <role>', 'This agent\'s role (implementer / architect / tester)') .requiredOption('--role <role>', 'This agent\'s role (implementer / architect / tester)')
.option('--user', 'Install for all projects (~/.claude) instead of just this one') .option('--cli <cli>', 'CLI to target: claude | codex | kimi (default: inferred from the agent name)')
.action((options: { agent: string; role: string; user?: boolean }) => { .option('--user', 'Claude Code only: install for all projects (~/.claude) instead of just this one')
agentSetup(cwd, { agent: options.agent, role: options.role, user: options.user }); .action((options: { agent: string; role: string; user?: boolean; cli?: string }) => {
agentSetup(cwd, { agent: options.agent, role: options.role, user: options.user, cli: options.cli });
}); });
program.addCommand(agentCmd); program.addCommand(agentCmd);

View File

@ -124,6 +124,8 @@ export const AgentConfigSchema = z.object({
/** Provider/company for the logo: "anthropic" | "openai" | "moonshot" | … */ /** Provider/company for the logo: "anthropic" | "openai" | "moonshot" | … */
kind: z.string().optional(), kind: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
/** Optional monthly cost budget in EUR — drives the budget bar on the board. */
budgetEur: z.number().nonnegative().optional(),
}); });
export const ConfigSchema = z.object({ export const ConfigSchema = z.object({

View File

@ -0,0 +1,198 @@
import { getTask, listTasks } from './taskService.js';
import { getRoster, inferKind, type RosterEntry } from './rosterService.js';
/**
* Token & cost accounting for the board.
*
* Honest by construction: external coding CLIs (Claude Code / Codex / Kimi) do
* NOT report their own token usage back to the hub, so we cannot show a metered
* ground truth for them. Instead we combine two clearly-labelled sources:
*
* 1. REAL `doneTokens` recorded on a task (via `task done --tokens`).
* 2. EST. an estimate derived from time-on-task: an active agent burns
* roughly TOKENS_PER_ACTIVE_MIN while a task sits in_progress /
* review, so the estimate grows live as work happens. Done tasks
* without a recorded count fall back to their total active span.
*
* Every estimated figure is flagged `estimated:true` so the UI can mark it
* with a "~" no silent fabrication.
*/
/** Tokens a working agent is assumed to spend per active minute (estimate). */
export const TOKENS_PER_ACTIVE_MIN = 3000;
/**
* Hard cap on ESTIMATED active minutes per task. Wall-clock time-in-state is a
* bad proxy for actual compute: a task can sit "in review" or "done" for days
* while the real work was minutes. Without this cap a task open for 12 days
* would estimate ~50M tokens. A single coding task rarely exceeds ~45 min of
* genuine agent work, so we clamp there real recorded tokens/duration are
* always used in preference and are never capped.
*/
export const MAX_ACTIVE_MIN_PER_TASK = 45;
/** USD → EUR display rate (rough, labelled approximate in the UI). */
const USD_TO_EUR = 0.92;
/**
* Blended USD price per 1M tokens per model family ( 75% input / 25% output of
* each vendor's published rate). Deliberately conservative and clearly an
* estimate used only for a directional cost figure, never billing.
*/
const MODEL_PRICING: Array<{ match: RegExp; usdPerMTok: number }> = [
{ match: /opus/i, usdPerMTok: 30 },
{ match: /sonnet/i, usdPerMTok: 6 },
{ match: /haiku/i, usdPerMTok: 1.6 },
{ match: /gpt-?5|codex|o[34]/i, usdPerMTok: 3.4 },
{ match: /kimi/i, usdPerMTok: 1.1 },
];
const DEFAULT_USD_PER_MTOK = 4;
function usdPerMTok(model?: string): number {
if (!model) return DEFAULT_USD_PER_MTOK;
for (const p of MODEL_PRICING) if (p.match.test(model)) return p.usdPerMTok;
return DEFAULT_USD_PER_MTOK;
}
function costEur(tokens: number, model?: string): number {
return (tokens / 1_000_000) * usdPerMTok(model) * USD_TO_EUR;
}
interface TaskTokens {
tokens: number;
estimated: boolean;
}
/** Tokens attributable to a single task, real if recorded else time-estimated. */
function tokensForTask(cwd: string, id: string, status: string, createdAt: string, updatedAt: string): TaskTokens {
const now = Date.now();
const created = Date.parse(createdAt);
const updated = Date.parse(updatedAt || createdAt);
let ms = 0;
if (status === 'done') {
// Prefer real recorded figures; only these agents' actual numbers are exact.
try {
const { task } = getTask(cwd, id);
if (typeof task.doneTokens === 'number' && task.doneTokens > 0) {
return { tokens: task.doneTokens, estimated: false };
}
if (typeof task.doneDuration === 'number' && task.doneDuration > 0) {
ms = task.doneDuration; // actual working duration, not calendar span
} else {
ms = (isNaN(updated) ? now : updated) - (isNaN(created) ? now : created);
}
} catch {
ms = (isNaN(updated) ? now : updated) - (isNaN(created) ? now : created);
}
} else if (status === 'in_progress' || status === 'review') {
ms = now - (isNaN(updated) ? now : updated); // since it was claimed / sent to review
} else {
return { tokens: 0, estimated: true }; // open / cancelled — no work yet
}
// Clamp: wall-clock time-in-state wildly overcounts for stale tasks.
const minutes = Math.min(Math.max(0, ms) / 60_000, MAX_ACTIVE_MIN_PER_TASK);
return { tokens: Math.round(minutes * TOKENS_PER_ACTIVE_MIN), estimated: true };
}
export interface AgentBudget {
name: string;
role: string;
model?: string;
kind?: string;
tokens: number;
realTokens: number;
estimatedTokens: number;
/** True if any part of this agent's total is an estimate. */
estimated: boolean;
costEur: number;
budgetEur?: number;
/** costEur / budgetEur, clamped 0..1 (only when a budget is set). */
budgetUsed?: number;
taskCount: number;
}
export interface BudgetReport {
agents: AgentBudget[];
totals: { tokens: number; costEur: number; estimated: boolean };
assumptions: { tokensPerActiveMin: number; note: string };
generatedAt: string;
}
/** Compute the per-agent token/cost rollup across all tasks. */
export function computeBudget(cwd: string): BudgetReport {
const roster = getRoster(cwd);
const byName = new Map<string, RosterEntry>();
for (const r of roster) byName.set(r.name, r);
const acc = new Map<string, AgentBudget>();
const ensure = (name: string): AgentBudget => {
let a = acc.get(name);
if (!a) {
const r = byName.get(name);
a = {
name,
role: r?.role ?? 'implementer',
model: r?.model,
kind: r?.kind ?? inferKind(name),
tokens: 0,
realTokens: 0,
estimatedTokens: 0,
estimated: false,
costEur: 0,
budgetEur: r?.budgetEur,
taskCount: 0,
};
acc.set(name, a);
}
return a;
};
// Seed rostered agents so they show even at zero.
for (const r of roster) ensure(r.name);
for (const t of listTasks(cwd)) {
const owner = t.assignedTo;
if (!owner) continue;
const { tokens, estimated } = tokensForTask(
cwd,
t.id,
t.status ?? 'open',
t.createdAt,
t.updatedAt,
);
if (tokens <= 0 && (t.status === 'open' || t.status === 'cancelled')) continue;
const a = ensure(owner);
a.tokens += tokens;
a.taskCount += 1;
if (estimated) {
a.estimatedTokens += tokens;
a.estimated = true;
} else {
a.realTokens += tokens;
}
}
const agents = Array.from(acc.values());
for (const a of agents) {
a.costEur = costEur(a.tokens, a.model);
if (a.budgetEur && a.budgetEur > 0) {
a.budgetUsed = Math.min(1, a.costEur / a.budgetEur);
}
}
agents.sort((x, y) => y.tokens - x.tokens || x.name.localeCompare(y.name));
const totalTokens = agents.reduce((s, a) => s + a.tokens, 0);
const totalCost = agents.reduce((s, a) => s + a.costEur, 0);
const anyEstimated = agents.some((a) => a.estimated);
return {
agents,
totals: { tokens: totalTokens, costEur: totalCost, estimated: anyEstimated },
assumptions: {
tokensPerActiveMin: TOKENS_PER_ACTIVE_MIN,
note: `External CLIs do not report tokens; figures marked ~ are estimated from active time-on-task (capped at ${MAX_ACTIVE_MIN_PER_TASK} min/task). Cost is a directional estimate at blended model rates, not billing.`,
},
generatedAt: new Date().toISOString(),
};
}

View File

@ -0,0 +1,65 @@
import { loadConfig } from '../config.js';
import { listTasks } from './taskService.js';
/** One agent in the team, resolved from the roster (config.agents) with a
* fallback to whatever agents actually touched tasks. */
export interface RosterEntry {
name: string;
role: string;
model?: string;
kind?: string;
description?: string;
budgetEur?: number;
}
/** Infer a provider ("kind") from an agent name when the roster doesn't say. */
export function inferKind(name: string): string | undefined {
const n = name.toLowerCase();
if (n.includes('claude') || n === 'backyard' || n === 'architect') return 'anthropic';
if (n.includes('codex') || n.includes('gpt')) return 'openai';
if (n.includes('kimi')) return 'moonshot';
return undefined;
}
/** Rough default display model per provider, for agents missing from the roster. */
function inferModel(kind?: string): string | undefined {
if (kind === 'anthropic') return 'Claude';
if (kind === 'openai') return 'GPT-5 Codex';
if (kind === 'moonshot') return 'Kimi K2';
return undefined;
}
/**
* The team roster: the named agents from `config.agents` if configured,
* otherwise one entry per agent that has touched a task (assignedTo / doneBy),
* so the board/budget views work even before a roster is filled in.
*/
export function getRoster(cwd: string): RosterEntry[] {
const config = loadConfig(cwd);
if (config.agents && Object.keys(config.agents).length > 0) {
return Object.entries(config.agents).map(([name, a]) => ({
name,
role: a.role,
model: a.model,
kind: a.kind ?? inferKind(name),
description: a.description,
budgetEur: a.budgetEur,
}));
}
// Fallback: derive from tasks + the role config's preferred agents.
const names = new Set<string>();
for (const [, cfg] of Object.entries(config.roles)) names.add(cfg.preferredAgent);
const roleByAgent = new Map<string, string>();
for (const [role, cfg] of Object.entries(config.roles)) roleByAgent.set(cfg.preferredAgent, role);
for (const t of listTasks(cwd)) {
if (t.assignedTo) names.add(t.assignedTo);
}
return Array.from(names).map((name) => {
const kind = inferKind(name);
return { name, role: roleByAgent.get(name) ?? 'implementer', kind, model: inferModel(kind) };
});
}

View File

@ -5,7 +5,7 @@ import { findProjectRoot } from '../core/paths.js';
import { loadConfig } from '../core/config.js'; import { loadConfig } from '../core/config.js';
import { remoteClient } from '../cli/remoteClient.js'; import { remoteClient } from '../cli/remoteClient.js';
import { parseSSEBuffer } from '../cli/commands/watch.js'; import { parseSSEBuffer } from '../cli/commands/watch.js';
import { findAddressedOpenTask, type AgentContext } from '../cli/commands/start.js'; import { findAddressedOpenTask, listReviewTasks, isReviewerRole, type AgentContext } from '../cli/commands/start.js';
import { import {
listTasks, listTasks,
getTask, getTask,
@ -105,10 +105,11 @@ export async function startMcpServer(cwd: string): Promise<void> {
}); });
server.tool('agenthub_work', server.tool('agenthub_work',
'Wait for a task addressed to you (newly delegated OR reopened), claim it, and return it with its handoff. Loop: work -> implement -> agenthub_task_review -> 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.',
{ agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional() }, { agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional() },
async ({ agent, role, timeoutSec }) => { 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);
// 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 () => { const drainInbox = async () => {
@ -135,18 +136,43 @@ export async function startMcpServer(cwd: string): Promise<void> {
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
// tasks addressed to you). Returns the pending review set — never claims.
const findReview = async () => {
const reviews = await listReviewTasks(ctx);
const messages = await drainInbox();
if (reviews.length) {
return {
reviews: reviews.map((r) => ({ id: r.id, title: r.title, assignedTo: r.assignedTo })),
messages,
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: [], messages, note: 'Nothing in review, but you have messages — reply with agenthub_message.' };
return null;
};
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.
const LOOP = const LOOP = reviewer
'STAY REACHABLE: the instant you finish handling this, call agenthub_work again — ' + ? 'STAY REACHABLE: the instant you finish reviewing, 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 review submission reaches you in realtime. ' +
'Never end your turn without relaunching agenthub_work.'; 'Never end your turn without relaunching agenthub_work.'
const immediate = await findWork(); : '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. ' +
'Never end your turn without relaunching agenthub_work.';
const immediate = await finder();
if (immediate) return asText({ ...immediate, loop: LOOP }); if (immediate) return asText({ ...immediate, loop: LOOP });
if (!remote) return asText(`No open task addressed to ${agent}, and no hub server to wait on.`); const emptyMsg = reviewer
const result = await waitForTask(serverUrl!, findWork, timeoutSec ?? 300); ? `No task in review for ${agent}, and no hub server to wait on.`
: `No open task addressed to ${agent}, and no hub server to wait on.`;
if (!remote) return asText(emptyMsg);
const result = await waitForTask(serverUrl!, finder, timeoutSec ?? 300);
if (result) return asText({ ...result, loop: LOOP }); if (result) return asText({ ...result, loop: LOOP });
return asText(`No task or message for ${agent} within ${timeoutSec ?? 300}s. ${LOOP}`); const kind = reviewer ? 'review submission' : 'task or message';
return asText(`No ${kind} for ${agent} within ${timeoutSec ?? 300}s. ${LOOP}`);
}); });
server.tool('agenthub_task_list', 'List tasks, optionally filtered by status and/or role.', server.tool('agenthub_task_list', 'List tasks, optionally filtered by status and/or role.',

View File

@ -434,6 +434,220 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
.sse-status { margin-left: auto; } .sse-status { margin-left: auto; }
.row { align-items: flex-start; flex-direction: column; gap: 4px; } .row { align-items: flex-start; flex-direction: column; gap: 4px; }
} }
/* ── New-task composer ──────────────────────────────────────────────── */
.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(--sans);
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; }
.composer {
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 260ms cubic-bezier(.2,.7,.2,1), opacity 200ms ease, margin 200ms ease;
margin: 0;
}
.composer.open { max-height: 240px; opacity: 1; margin: 0 0 14px; }
.composer-inner {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
}
.composer input, .composer select {
background: var(--raised);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font: 13px/1.3 var(--sans);
padding: 8px 10px;
min-height: 36px;
}
.composer input:focus, .composer select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.composer input.title { flex: 1 1 260px; min-width: 200px; }
.composer .create {
min-height: 36px;
padding: 0 16px;
border-radius: 8px;
border: 1px solid var(--green);
background: rgba(34,197,94,.16);
color: #d1fadf;
font: 600 13px/1 var(--sans);
cursor: pointer;
transition: background 160ms ease, transform 120ms ease;
}
.composer .create:hover { background: rgba(34,197,94,.26); }
.composer .create:active { transform: translateY(1px); }
.composer .hint { color: var(--muted); font-size: 11px; flex-basis: 100%; }
/* ── Agents rail (drop targets) ─────────────────────────────────────── */
.agents-rail {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: stretch;
margin-bottom: 14px;
}
.agents-rail .rail-label {
align-self: center;
color: var(--muted);
font: 11px/1 var(--mono);
text-transform: uppercase;
letter-spacing: .06em;
margin-right: 2px;
}
.agent-chip {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 12px 8px 9px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: inset 3px 0 0 var(--agent-color);
transition: border-color 160ms ease, transform 140ms ease, box-shadow 160ms ease, background 160ms ease;
cursor: grab;
}
.agent-chip.dragging { opacity: .5; cursor: grabbing; transform: scale(.97); }
.agent-chip .ac-core {
width: 26px; height: 26px; border-radius: 50%;
display: inline-grid; place-items: center;
background: var(--agent-color); color: #fff;
font: 800 10px/1 var(--mono); flex: 0 0 auto;
}
.agent-chip .ac-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.agent-chip .ac-name { font-weight: 650; font-size: 13px; white-space: nowrap; }
.agent-chip .ac-sub { color: var(--muted); font: 10.5px/1.2 var(--mono); white-space: nowrap; }
.agent-chip .ac-busy {
width: 8px; height: 8px; border-radius: 50%;
background: var(--muted); flex: 0 0 auto; margin-left: 2px;
}
.agent-chip.busy .ac-busy { background: var(--in_progress); box-shadow: 0 0 0 3px rgba(88,166,255,.16); animation: busyPulse 1.6s ease-in-out infinite; }
.agent-chip.busy { background: rgba(88,166,255,.06); }
.agent-chip.drop-active {
border-color: var(--green);
box-shadow: inset 3px 0 0 var(--agent-color), 0 0 0 2px rgba(34,197,94,.35);
transform: translateY(-2px);
}
.agent-chip.ping { animation: agentPing 700ms ease; }
@keyframes busyPulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
@keyframes agentPing {
0% { box-shadow: inset 3px 0 0 var(--agent-color), 0 0 0 0 rgba(34,197,94,.55); }
100% { box-shadow: inset 3px 0 0 var(--agent-color), 0 0 0 16px rgba(34,197,94,0); }
}
/* ── Drag & drop ────────────────────────────────────────────────────── */
.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(34,197,94,.4); }
100% { border-color: var(--border); box-shadow: none; }
}
.card { 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: #1a2436; box-shadow: 0 0 0 1px rgba(88,166,255,.4) inset; }
.column.drop-active .col-head { color: var(--accent); }
.column .cards.drop-hover { outline: 1px dashed rgba(88,166,255,.5); outline-offset: 3px; border-radius: 6px; }
/* Card as an assign drop-target (an agent chip dragged onto it). */
.card.drop-active { border-color: var(--green); background: #172a1d; box-shadow: 0 0 0 2px rgba(34,197,94,.45); transform: translateY(-1px); }
/* ── Budget panel ───────────────────────────────────────────────────── */
.budget {
margin-top: 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 16px;
}
.budget-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.budget-head h2 { font-size: 14px; margin: 0; font-weight: 700; }
.budget-head .est-note { color: var(--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(--muted); font-weight: 400; }
.budget-row {
display: grid;
grid-template-columns: 190px 1fr 128px;
align-items: center;
gap: 12px;
padding: 9px 0;
border-top: 1px solid var(--border);
}
.budget-row:first-of-type { border-top: 0; }
.budget-agent { display: flex; align-items: center; gap: 9px; 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: #fff; font: 800 9px/1 var(--mono); flex: 0 0 auto;
}
.budget-agent .ba-name { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.budget-agent .ba-model { color: var(--muted); font: 10.5px/1.2 var(--mono); white-space: nowrap; }
.budget-bar { height: 8px; border-radius: 999px; background: var(--raised); overflow: hidden; position: relative; }
.budget-bar > span {
display: block; height: 100%;
background: linear-gradient(90deg, var(--in_progress), #7ee2a8);
width: 0; transition: width 700ms cubic-bezier(.2,.7,.2,1);
}
.budget-bar.over > span { background: linear-gradient(90deg, var(--review), var(--danger)); }
.budget-tokens { text-align: right; font: 12px/1.3 var(--mono); }
.budget-tokens .tok { color: var(--text); font-weight: 600; }
.budget-tokens .est { color: var(--muted); }
.budget-tokens .eur { color: var(--muted); display: block; font-size: 11px; }
.budget-empty { color: var(--muted); font-size: 12px; padding: 6px 0; }
/* ── Toasts ─────────────────────────────────────────────────────────── */
.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: var(--raised);
border: 1px solid var(--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(34,197,94,.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); } }
@media (max-width: 640px) {
.budget-row { grid-template-columns: 1fr 100px; }
.budget-bar { display: none; }
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { *, *::before, *::after {
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
@ -464,6 +678,9 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
<a class="nav-link" href="/activity">Activity</a> <a class="nav-link" href="/activity">Activity</a>
<a class="nav-link" href="/decisions">Decisions</a> <a class="nav-link" href="/decisions">Decisions</a>
</nav> </nav>
<button class="new-task-btn" id="newTaskBtn" type="button" aria-expanded="false">
<span class="plus" aria-hidden="true">+</span> New task
</button>
<span class="sse-status stale" id="sseStatus"> <span class="sse-status stale" id="sseStatus">
<span class="conn-dot" aria-hidden="true"></span> <span class="conn-dot" aria-hidden="true"></span>
<span id="sseLabel">connecting</span> <span id="sseLabel">connecting</span>
@ -471,6 +688,29 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
</header> </header>
<main> <main>
<div class="composer" id="composer">
<form class="composer-inner" id="composerForm" autocomplete="off">
<input class="title" id="ctTitle" type="text" placeholder="What needs doing? (task title)" required maxlength="140" />
<select id="ctRole" aria-label="Role">
<option value="">any role</option>
<option value="implementer" selected>implementer</option>
<option value="reviewer">reviewer</option>
<option value="tester">tester</option>
<option value="architect">architect</option>
</select>
<select id="ctPriority" aria-label="Priority">
<option value="low">low</option>
<option value="medium" selected>medium</option>
<option value="high">high</option>
<option value="critical">critical</option>
</select>
<button class="create" type="submit">Create</button>
<div class="hint">Tip: start the title with an agent name (e.g. <code>codex: </code>) to address it moving the card to <b>In&nbsp;Progress</b> then auto-assigns it. Or drag an agent onto a card to assign manually.</div>
</form>
</div>
<section class="agents-rail" id="agentsRail" aria-label="Agents — drag one onto a task to assign"></section>
<section class="dashboard" aria-label="Dashboard metrics"> <section class="dashboard" aria-label="Dashboard metrics">
<div class="metrics" id="metrics"> <div class="metrics" id="metrics">
<div class="metric-card"><div class="metric-label">Active tasks</div><div class="metric-value" data-metric="active">0</div><span class="metric-icon" style="color:var(--accent)"></span></div> <div class="metric-card"><div class="metric-label">Active tasks</div><div class="metric-value" data-metric="active">0</div><span class="metric-icon" style="color:var(--accent)"></span></div>
@ -485,8 +725,19 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
<section class="board" id="board" aria-label="Task board"> <section class="board" id="board" aria-label="Task board">
${columnSkeleton()} ${columnSkeleton()}
</section> </section>
<section class="budget" id="budget" aria-label="Token and cost budget">
<div class="budget-head">
<h2>Cost &amp; Budget</h2>
<span class="est-note" id="budgetNote">estimated from time-on-task</span>
<span class="total" id="budgetTotal"></span>
</div>
<div id="budgetRows"><div class="budget-empty">no agent activity yet</div></div>
</section>
</main> </main>
<div class="toasts" id="toasts" aria-live="polite"></div>
<script> <script>
var REFRESH_MS = 6000; var REFRESH_MS = 6000;
var TIMER_MS = 1000; var TIMER_MS = 1000;
@ -611,7 +862,8 @@ ${columnSkeleton()}
function taskCard(t) { function taskCard(t) {
var status = byStatus(t.status); var status = byStatus(t.status);
return '<a class="card" href="/tasks/' + encodeURIComponent(t.id) + '" data-id="' + esc(t.id) + '">' + return '<a class="card" draggable="true" href="/tasks/' + encodeURIComponent(t.id) + '"' +
' data-id="' + esc(t.id) + '" data-status="' + esc(status) + '" data-assigned="' + esc(t.assignedTo || '') + '">' +
'<div class="card-top">' + '<div class="card-top">' +
'<span class="id">' + esc(t.id) + '</span>' + '<span class="id">' + esc(t.id) + '</span>' +
'<span class="pill status-pill status-' + esc(status) + '">' + esc(statusLabel(status)) + '</span>' + '<span class="pill status-pill status-' + esc(status) + '">' + esc(statusLabel(status)) + '</span>' +
@ -693,8 +945,10 @@ ${columnSkeleton()}
try { try {
var status = await getJSON('/status'); var status = await getJSON('/status');
var body = String(status && status.body ? status.body : ''); 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*:?\\s*(.+)$/m); var m = body.match(/^#\\s+AgentHub\\s+[\\u2014-]\\s+(.+)$/m) || body.match(/^#\\s+Project\\s+Status[^\\S\\n]*[:\\-][^\\S\\n]*(.+)$/m);
applyProjectName(m && m[1] ? m[1].trim() : PROJECT_NAME); 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 (_) { } catch (_) {
applyProjectName(PROJECT_NAME); applyProjectName(PROJECT_NAME);
} }
@ -703,6 +957,7 @@ ${columnSkeleton()}
try { try {
var tasks = await getJSON('/tasks'); var tasks = await getJSON('/tasks');
renderBoard(tasks); renderBoard(tasks);
renderAgentsRail();
setConn('ok', eventSourceReady ? 'connected' : 'polling'); setConn('ok', eventSourceReady ? 'connected' : 'polling');
} catch (_) { } catch (_) {
setConn('down', 'offline'); setConn('down', 'offline');
@ -736,6 +991,7 @@ ${columnSkeleton()}
stopFallbackPoll(); stopFallbackPoll();
setConn('ok', 'connected'); setConn('ok', 'connected');
refresh(); refresh();
refreshBudget();
}; };
source.onerror = function() { source.onerror = function() {
eventSourceReady = false; eventSourceReady = false;
@ -744,10 +1000,287 @@ ${columnSkeleton()}
}; };
} }
// ── Roster + agents rail ────────────────────────────────────────────────
var AGENTS = [];
var BUDGET = null;
function agentBusyMap() {
var m = {};
(lastTasks || []).forEach(function(t) {
if (t.status === 'in_progress' && t.assignedTo) m[t.assignedTo] = true;
});
return m;
}
function renderAgentsRail() {
var rail = document.getElementById('agentsRail');
if (!rail) return;
if (!AGENTS.length) { rail.innerHTML = ''; return; }
var busy = agentBusyMap();
var html = '<span class="rail-label">drag onto a task &rarr;</span>';
html += AGENTS.map(function(a) {
var spec = agentSpec(a.name);
var b = busy[a.name];
return '<div class="agent-chip' + (b ? ' busy' : '') + '" draggable="true" data-agent="' + esc(a.name) + '"' +
' style="--agent-color:' + esc(spec.color) + '" title="Drag ' + esc(a.name) + ' onto a task card to assign it (notified in realtime)">' +
'<span class="ac-core">' + esc(spec.initials) + '</span>' +
'<span class="ac-meta"><span class="ac-name">' + esc(a.name) + '</span>' +
'<span class="ac-sub">' + esc(a.model || a.role || '') + '</span></span>' +
'<span class="ac-busy" aria-hidden="true"></span>' +
'</div>';
}).join('');
rail.innerHTML = html;
}
async function loadAgents() {
try {
AGENTS = await getJSON('/agents');
} catch (_) { AGENTS = []; }
renderAgentsRail();
}
// ── 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 renderBudget(rep) {
BUDGET = rep;
var rows = document.getElementById('budgetRows');
var total = document.getElementById('budgetTotal');
if (!rows || !total) return;
var active = (rep && rep.agents ? rep.agents : []).filter(function(a) { return a.tokens > 0; });
if (!active.length) {
rows.innerHTML = '<div class="budget-empty">no tokens spent yet — assign a task to an agent to see live estimates</div>';
total.textContent = '';
return;
}
var mx = maxTok(active) || 1;
rows.innerHTML = active.map(function(a) {
var spec = agentSpec(a.name);
var est = a.estimated ? '<span class="est">~</span>' : '';
var pct, over = false;
if (a.budgetEur) { pct = Math.round((a.budgetUsed || 0) * 100); over = pct >= 100; }
else { pct = Math.round((a.tokens / mx) * 100); }
var money = a.budgetEur
? fmtEur(a.costEur) + ' / ' + fmtEur(a.budgetEur)
: '\\u2248 ' + fmtEur(a.costEur);
return '<div class="budget-row">' +
'<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><br>' +
'<span class="ba-model">' + esc(a.model || a.role || '') + '</span></span></div>' +
'<div class="budget-bar' + (over ? ' over' : '') + '"><span style="width:' + Math.min(100, pct) + '%"></span></div>' +
'<div class="budget-tokens"><span class="tok">' + est + fmtTokens(a.tokens) + ' tok</span>' +
'<span class="eur">' + money + '</span></div>' +
'</div>';
}).join('');
total.innerHTML = (rep.totals.estimated ? '~' : '') + fmtTokens(rep.totals.tokens) +
' tok <small>\\u00b7 \\u2248 ' + fmtEur(rep.totals.costEur) + '</small>';
}
async function refreshBudget() {
try { renderBudget(await getJSON('/budget')); } catch (_) {}
}
// ── 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);
}
function pingAgent(agent) {
var chip = document.querySelector('.agent-chip[data-agent="' + agent + '"]');
if (!chip) return;
chip.classList.remove('ping'); void chip.offsetWidth; chip.classList.add('ping');
}
// 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 : '';
}
// Manual (re)assign: an agent chip was dragged onto a task card.
async function assignAgentToCard(id, agent) {
try {
await patchTask(id, { assignedTo: agent });
pingAgent(agent);
toast(id + ' \\u2192 @' + agent + ' \\u00b7 notified in realtime');
await refresh(); await refreshBudget(); flashCard(id);
} catch (e) { toast(e.message || 'assign failed', { error: true }); }
}
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') {
// Auto-assign: use the current assignee, else the agent named in the title.
var agent = assigned || agentFromTitle(id);
if (!agent) {
toast('No agent — start the title with "name:" or drag an agent onto the card', { error: true, ms: 4200 });
return;
}
await patchTask(id, { status: 'in_progress', assignedTo: agent });
pingAgent(agent);
toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (assigned ? '' : ' (from title)'));
} else if (status === 'review') {
await patchTask(id, { status: 'review' });
toast(id + ' \\u2192 review');
} 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) ───────────────────────────────
// Two drag kinds:
// • 'card' → drop on a COLUMN to change status (in_progress auto-assigns).
// • 'agent' → drop on a CARD to (re)assign that agent to the task.
var dragKind = null, dragId = null, dragAgent = null, lastTarget = null;
function dropTargetOf(e) {
var n = e.target;
if (!n || !n.closest) return null;
if (dragKind === 'agent') return n.closest('.card');
if (dragKind === 'card') return n.closest('.column');
return null;
}
document.addEventListener('dragstart', function(e) {
var chip = e.target.closest && e.target.closest('.agent-chip');
if (chip) {
dragKind = 'agent'; dragAgent = chip.dataset.agent;
chip.classList.add('dragging');
if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'copy'; try { e.dataTransfer.setData('text/plain', dragAgent); } catch (_) {} }
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('.agent-chip') || 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; dragAgent = null;
});
document.addEventListener('dragover', function(e) {
if (!dragKind) return;
var tgt = dropTargetOf(e);
if (!tgt) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = dragKind === 'agent' ? 'copy' : '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');
if (dragKind === 'agent') {
assignAgentToCard(tgt.dataset.id, dragAgent);
} else {
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);
// ── New-task composer ───────────────────────────────────────────────────
(function() {
var btn = document.getElementById('newTaskBtn');
var panel = document.getElementById('composer');
var form = document.getElementById('composerForm');
if (!btn || !panel || !form) return;
btn.addEventListener('click', function() {
var open = panel.classList.toggle('open');
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) { var t = document.getElementById('ctTitle'); if (t) t.focus(); }
});
form.addEventListener('submit', async function(e) {
e.preventDefault();
var title = (document.getElementById('ctTitle').value || '').trim();
if (!title) return;
var role = document.getElementById('ctRole').value || undefined;
var priority = document.getElementById('ctPriority').value || undefined;
try {
var task = await postTask({ title: title, role: role, priority: priority });
toast(task.id + ' created');
document.getElementById('ctTitle').value = '';
panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false');
await refresh(); await refreshBudget(); flashCard(task.id);
} catch (err) { toast(err.message || 'create failed', { error: true }); }
});
})();
loadStatusMeta(); loadStatusMeta();
loadAgents();
refresh(); refresh();
refreshBudget();
connectEvents(); connectEvents();
setInterval(updateTimers, TIMER_MS); setInterval(updateTimers, TIMER_MS);
setInterval(refreshBudget, 3000);
</script> </script>
</body> </body>
</html> </html>

View File

@ -1,6 +1,7 @@
import Fastify from 'fastify'; import Fastify from 'fastify';
import { registerRoutes } from './routes.js'; import { registerRoutes } from './routes.js';
import { resolveAdvertiseUrl, startDiscoveryBroadcaster } from '../discovery.js'; import { resolveAdvertiseUrl, startDiscoveryBroadcaster } from '../discovery.js';
import { startMdnsAdvertise, type MdnsHandle } from './mdns.js';
import { startEntityWatcher } from './fsWatch.js'; import { startEntityWatcher } from './fsWatch.js';
import { startStatusAutoRefresh } from './statusRefresh.js'; import { startStatusAutoRefresh } from './statusRefresh.js';
@ -16,12 +17,14 @@ export async function startServer(cwd: string, options: { port?: number; host?:
const host = options.host ?? '127.0.0.1'; const host = options.host ?? '127.0.0.1';
let broadcaster: ReturnType<typeof startDiscoveryBroadcaster> | undefined; let broadcaster: ReturnType<typeof startDiscoveryBroadcaster> | undefined;
let mdns: MdnsHandle | undefined;
// Emit SSE events for CLI/file writes too, not just REST mutations. // Emit SSE events for CLI/file writes too, not just REST mutations.
const stopWatcher = startEntityWatcher(cwd); const stopWatcher = startEntityWatcher(cwd);
// Keep status/latest.md fresh on every change so agents never read a stale snapshot. // Keep status/latest.md fresh on every change so agents never read a stale snapshot.
const stopStatusRefresh = startStatusAutoRefresh(cwd); const stopStatusRefresh = startStatusAutoRefresh(cwd);
app.addHook('onClose', async () => { app.addHook('onClose', async () => {
broadcaster?.stop(); broadcaster?.stop();
mdns?.stop();
stopWatcher(); stopWatcher();
stopStatusRefresh(); stopStatusRefresh();
}); });
@ -36,6 +39,13 @@ export async function startServer(cwd: string, options: { port?: number; host?:
const advertiseUrl = resolveAdvertiseUrl(host, actualPort); const advertiseUrl = resolveAdvertiseUrl(host, actualPort);
broadcaster = startDiscoveryBroadcaster(advertiseUrl); broadcaster = startDiscoveryBroadcaster(advertiseUrl);
// Advertise a browsable LAN hostname over mDNS (best-effort).
mdns = startMdnsAdvertise({ port: actualPort });
if (mdns) {
const portSuffix = actualPort === 80 ? '' : `:${actualPort}`;
console.log(`AgentHub: reachable in a browser at http://${mdns.hostname}${portSuffix}`);
}
return { app, url }; return { app, url };
} catch (err) { } catch (err) {
const e = err as NodeJS.ErrnoException; const e = err as NodeJS.ErrnoException;

52
src/server/mdns.ts Normal file
View File

@ -0,0 +1,52 @@
import { Bonjour, type Service } from 'bonjour-service';
/**
* Advertise the hub over mDNS/Bonjour as `<name>.local`, so anyone on the LAN
* can open it in a browser without knowing the IP e.g.
* `http://agenthub.local:3377` (or just `http://agenthub.local` when the hub
* runs on port 80).
*
* Best-effort, exactly like the UDP discovery broadcaster: if mDNS is
* unavailable or multicast is blocked on the network, we quietly carry on
* the IP + UDP discovery still work.
*/
export interface MdnsHandle {
hostname: string;
stop: () => void;
}
export function startMdnsAdvertise(opts: { port: number; name?: string }): MdnsHandle | undefined {
const base = (opts.name ?? 'agenthub').toLowerCase().replace(/[^a-z0-9-]/g, '') || 'agenthub';
const hostname = `${base}.local`;
try {
const bonjour = new Bonjour();
const service: Service = bonjour.publish({
name: 'AgentHub',
type: 'http',
port: opts.port,
host: hostname,
txt: { path: '/board' },
});
// Swallow responder errors — advertisement is optional infrastructure.
service.on('error', () => {
/* best-effort */
});
return {
hostname,
stop: () => {
try {
service.stop?.();
} catch {
/* ignore */
}
try {
bonjour.destroy();
} catch {
/* ignore */
}
},
};
} catch {
return undefined;
}
}

View File

@ -7,6 +7,8 @@ import { createMessage, listMessages, listInbox, markMessageRead } from '../core
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';
import { computeBudget } from '../core/services/budgetService.js';
import { getRoster } from '../core/services/rosterService.js';
import { loadConfig } from '../core/config.js'; import { loadConfig } from '../core/config.js';
import { renderActivityHtml } from './activity.js'; import { renderActivityHtml } from './activity.js';
import { renderBoardHtml } from './board.js'; import { renderBoardHtml } from './board.js';
@ -128,6 +130,12 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
app.get('/status', async () => ({ body: getStatus(cwd) })); app.get('/status', async () => ({ body: getStatus(cwd) }));
app.post('/status/update', async () => ({ body: updateStatus(cwd) })); app.post('/status/update', async () => ({ body: updateStatus(cwd) }));
// Team roster (JSON) — used by the board's agent rail / drop targets.
app.get('/agents', async () => getRoster(cwd));
// Token & cost rollup per agent (real recorded + time-estimated, clearly flagged).
app.get('/budget', async () => computeBudget(cwd));
// ─── 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 };

View File

@ -70,14 +70,9 @@ function attachBusy(cwd: string, roster: RosterAgent[]): RosterAgent[] {
function statusLine(a: RosterAgent): string { function statusLine(a: RosterAgent): string {
const busy = a.busyTask; const busy = a.busyTask;
if (busy) { if (busy) {
return `<div style="margin-top:10px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;"> return `<div class="agent-status"><span class="st-dot busy"></span><a class="st-link" href="/tasks/${escapeHtml(busy.id)}">${escapeHtml(busy.id)} &middot; claimed &hellip;</a></div>`;
${statusPill('in_progress')}
<a href="/tasks/${escapeHtml(busy.id)}" style="color:var(--accent);text-decoration:none;font-family:var(--font-mono);font-size:12px;" data-live-timer data-timer-at="${escapeHtml(busy.updatedAt)}" data-timer-mode="in_progress">claimed</a>
</div>`;
} }
return `<div style="margin-top:10px;display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px;"> return `<div class="agent-status"><span class="st-dot"></span><span class="st-free">free</span></div>`;
<span style="width:8px;height:8px;border-radius:50%;background:var(--green);"></span> free
</div>`;
} }
/** /**
@ -97,7 +92,7 @@ function agentCard(a: RosterAgent, opts: { avatar: 'logo' | 'monogram'; architec
: ''; : '';
return ` return `
<div class="agent-card" style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px 14px;min-width:212px;"> <div class="agent-card${a.busyTask ? ' busy' : ''}" data-agent="${escapeHtml(a.name)}" data-role="${escapeHtml(a.role)}" style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px 14px;min-width:212px;">
<div style="display:flex;align-items:center;gap:10px;"> <div style="display:flex;align-items:center;gap:10px;">
${avatar} ${avatar}
<div style="min-width:0;"> <div style="min-width:0;">
@ -120,7 +115,7 @@ function providerGroup(kind: string, agents: RosterAgent[]): string {
${providerLogo(kind, 18)} ${providerLogo(kind, 18)}
<span style="font-weight:600;font-size:13px;">${meta ? escapeHtml(meta.name) : escapeHtml(kind)}</span> <span style="font-weight:600;font-size:13px;">${meta ? escapeHtml(meta.name) : escapeHtml(kind)}</span>
<span style="flex:1;"></span> <span style="flex:1;"></span>
<span style="color:var(--muted);font-size:11px;font-family:var(--font-mono);">${busy}/${agents.length} busy</span> <span class="pg-busy" data-group="${escapeHtml(kind)}" data-total="${agents.length}" style="color:var(--muted);font-size:11px;font-family:var(--font-mono);">${busy}/${agents.length} busy</span>
</div> </div>
${agents.map((a) => agentCard(a, { avatar: 'monogram' })).join('')} ${agents.map((a) => agentCard(a, { avatar: 'monogram' })).join('')}
</div>`; </div>`;
@ -190,10 +185,51 @@ export function renderTeamHtml(cwd: string): string {
.tier-row { display:flex; flex-wrap:wrap; justify-content:center; gap:14px; } .tier-row { display:flex; flex-wrap:wrap; justify-content:center; gap:14px; }
.provider-row { display:flex; flex-wrap:wrap; justify-content:center; align-items:flex-start; gap:18px; width:100%; } .provider-row { display:flex; flex-wrap:wrap; justify-content:center; align-items:flex-start; gap:18px; width:100%; }
.provider-group { min-width:240px; max-width:300px; } .provider-group { min-width:240px; max-width:300px; }
.connector-down { width:1px; height:28px; background:var(--border); flex:0 0 auto; } /* Connectors always carry a subtle ambient shimmer, so the tree looks alive
even at rest; when an agent is working the flow brightens and speeds up. */
.connector-down {
width:2px; height:28px; flex:0 0 auto; border-radius:2px;
background: linear-gradient(180deg, var(--border) 0%, rgba(88,166,255,.30) 50%, var(--border) 100%);
background-size: 100% 240%;
animation: flowDown 4.2s linear infinite;
}
.tree.live .connector-down {
background-image: linear-gradient(180deg, var(--border) 0%, var(--accent) 45%, var(--border) 90%);
animation-duration: 1.8s;
}
@keyframes flowDown { from { background-position: 0 130%; } to { background-position: 0 -130%; } }
.agent-card { transition: border-color 150ms ease, transform 150ms ease; } .agent-card {
transition: border-color 150ms ease, transform 150ms ease, box-shadow 220ms ease, background 220ms ease;
animation: cardEnter 260ms cubic-bezier(.2,.7,.2,1) both, idleGlow 5.5s ease-in-out infinite .3s;
}
@keyframes cardEnter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
/* Faint neutral breathing at rest — alive, but clearly not "busy" (which is accent-blue). */
@keyframes idleGlow {
0%,100% { box-shadow: 0 0 0 1px rgba(148,163,184,.04); }
50% { box-shadow: 0 0 0 1px rgba(148,163,184,.12), 0 4px 16px rgba(0,0,0,.16); }
}
.agent-card:hover { border-color: rgba(88,166,255,0.5); transform: translateY(-1px); } .agent-card:hover { border-color: rgba(88,166,255,0.5); transform: translateY(-1px); }
/* Busy agent: soft breathing glow + accent border (overrides idle breathing). */
.agent-card.busy { border-color: rgba(88,166,255,.5); animation: cardBusy 2.6s ease-in-out infinite; }
@keyframes cardBusy {
0%,100% { box-shadow: 0 0 0 1px rgba(88,166,255,.16); }
50% { box-shadow: 0 0 0 1px rgba(88,166,255,.42), 0 6px 22px rgba(88,166,255,.12); }
}
.agent-card.ping { animation: cardPing 720ms ease; }
@keyframes cardPing {
0% { box-shadow: 0 0 0 0 rgba(34,197,94,.5); }
100% { box-shadow: 0 0 0 16px rgba(34,197,94,0); }
}
/* Status line */
.agent-status { margin-top:10px; display:flex; align-items:center; gap:8px; font-size:12px; }
.st-dot { width:8px; height:8px; border-radius:50%; background:var(--green); flex:0 0 auto; }
.st-dot.busy { background:var(--status-in_progress); box-shadow:0 0 0 3px rgba(88,166,255,.16); animation: dotPulse 1.5s ease-in-out infinite; }
@keyframes dotPulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
.st-free { color: var(--muted); }
.st-link { color: var(--accent); text-decoration: none; font-family: var(--font-mono); font-size: 12px; }
.st-link:hover { text-decoration: underline; }
@media (max-width: 640px) { @media (max-width: 640px) {
.provider-row { flex-direction:column; align-items:center; gap:14px; } .provider-row { flex-direction:column; align-items:center; gap:14px; }
@ -207,7 +243,81 @@ export function renderTeamHtml(cwd: string): string {
${tiers} ${tiers}
</main> </main>
<script> <script>
${liveTimerJs()} (function() {
var connDot = document.getElementById('conn-dot');
function setConn(ok) {
if (!connDot) return;
connDot.style.background = ok ? 'var(--green)' : 'var(--status-review)';
connDot.title = ok ? 'connected' : 'reconnecting';
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function ago(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 updateGroupCounts() {
document.querySelectorAll('.pg-busy').forEach(function(el) {
var group = el.closest('.provider-group');
var total = el.getAttribute('data-total') || '0';
var busy = group ? group.querySelectorAll('.agent-card.busy').length : 0;
el.textContent = busy + '/' + total + ' busy';
});
}
async function sync() {
var tasks;
try {
tasks = await fetch('/tasks', { headers: { accept: 'application/json' } }).then(function(r) { return r.json(); });
} catch (e) { setConn(false); return; }
setConn(true);
var busy = {};
(tasks || []).forEach(function(t) {
if (t.status === 'in_progress' && t.assignedTo) {
if (!busy[t.assignedTo] || t.updatedAt > busy[t.assignedTo].updatedAt) busy[t.assignedTo] = t;
}
});
var anyBusy = false;
document.querySelectorAll('.agent-card[data-agent]').forEach(function(card) {
var name = card.getAttribute('data-agent');
var t = busy[name];
var statusEl = card.querySelector('.agent-status');
if (t) {
anyBusy = true;
if (!card.classList.contains('busy')) {
card.classList.add('busy');
card.classList.remove('ping'); void card.offsetWidth; card.classList.add('ping');
}
if (statusEl) {
statusEl.innerHTML = '<span class="st-dot busy"></span><a class="st-link" href="/tasks/' +
esc(t.id) + '">' + esc(t.id) + ' \\u00b7 claimed ' + esc(ago(t.updatedAt)) + '</a>';
}
} else {
card.classList.remove('busy');
if (statusEl) statusEl.innerHTML = '<span class="st-dot"></span><span class="st-free">free</span>';
}
});
var tree = document.getElementById('tree');
if (tree) tree.classList.toggle('live', anyBusy);
updateGroupCounts();
}
sync();
setInterval(sync, 1000); // keep "claimed Xs" ticking + reflect changes even without SSE
if ('EventSource' in window) {
var es = new EventSource('/events');
es.onopen = function() { setConn(true); };
es.onmessage = function() { sync(); };
es.onerror = function() { setConn(false); };
}
})();
</script> </script>
</body> </body>
</html>`; </html>`;

View File

@ -196,7 +196,8 @@ describe('server routes', () => {
expect(html).toContain('data-id'); expect(html).toContain('data-id');
expect(html).toContain('href="/tasks/'); expect(html).toContain('href="/tasks/');
expect(html).not.toContain('class="timeline"'); expect(html).not.toContain('class="timeline"');
expect(html).not.toContain('expanded'); // Cards must not expand inline (aria-expanded on the composer button is fine).
expect(html).not.toMatch(/class="[^"]*\bexpanded\b/);
expect(html).not.toContain("'/tasks/' + encodeURIComponent(id) + '/activity'"); expect(html).not.toContain("'/tasks/' + encodeURIComponent(id) + '/activity'");
}); });

View File

@ -55,7 +55,9 @@ describe('GET /team', () => {
const res = await app.inject({ method: 'GET', url: '/team' }); const res = await app.inject({ method: 'GET', url: '/team' });
const html = res.payload; const html = res.payload;
expect(html).toContain('TSK-0001'); expect(html).toContain('TSK-0001');
expect(html).toContain('data-live-timer'); // The busy agent's card is tagged for the live client sync and rendered busy.
expect(html).toContain('data-agent="codex"');
expect(html).toContain('agent-card busy');
expect(html).toContain('claimed'); expect(html).toContain('claimed');
}); });
}); });