Compare commits
No commits in common. "5586f2c15b1e8590004f74c6fa229a4368990982" and "b5f75c6c659d03b56c73d1bd93e66c8a1835e760" have entirely different histories.
5586f2c15b
...
b5f75c6c65
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "0.7.0",
|
||||
"version": "0.6.1",
|
||||
"description": "Local coordination layer for AI coding agents",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@ -23,7 +23,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/prompts": "^7.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"better-sqlite3": "^12.0.0",
|
||||
"commander": "^13.0.0",
|
||||
"fastify": "^5.0.0",
|
||||
|
||||
671
pnpm-lock.yaml
generated
671
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -11,7 +11,6 @@ import { update } from './commands/update.js';
|
||||
import { watchEvents } from './commands/watch.js';
|
||||
import { startAgent } from './commands/start.js';
|
||||
import { workAgent } from './commands/work.js';
|
||||
import { startMcpServer } from '../mcp/server.js';
|
||||
import { loadConfig, saveConfig } from '../core/config.js';
|
||||
import { findProjectRoot } from '../core/paths.js';
|
||||
import { discoverServer } from '../discovery.js';
|
||||
@ -115,7 +114,7 @@ async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<vo
|
||||
export function createProgram(cwd: string): Command {
|
||||
const program = new Command('agenthub')
|
||||
.description('Local coordination layer for AI coding agents')
|
||||
.version('0.7.0')
|
||||
.version('0.6.1')
|
||||
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
||||
|
||||
program
|
||||
@ -482,14 +481,6 @@ export function createProgram(cwd: string): Command {
|
||||
await update();
|
||||
});
|
||||
|
||||
// ─── mcp ───────────────────────────────────────────────────────────────────
|
||||
program
|
||||
.command('mcp')
|
||||
.description('Start the MCP server (stdio) — exposes hub tools to MCP-capable agents (Claude/Codex/Kimi)')
|
||||
.action(async () => {
|
||||
await startMcpServer(cwd);
|
||||
});
|
||||
|
||||
// ─── hello (presence) ──────────────────────────────────────────────────────
|
||||
program
|
||||
.command('hello')
|
||||
|
||||
@ -2,7 +2,7 @@ import { createProgram } from './cli/index.js';
|
||||
import { maybeNotifyUpdate } from './cli/commands/update.js';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
if (!argv.includes('update') && !argv.includes('server') && !argv.includes('mcp')) {
|
||||
if (!argv.includes('update') && !argv.includes('server')) {
|
||||
maybeNotifyUpdate();
|
||||
}
|
||||
|
||||
|
||||
@ -1,194 +0,0 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import { findProjectRoot } from '../core/paths.js';
|
||||
import { loadConfig } from '../core/config.js';
|
||||
import { remoteClient } from '../cli/remoteClient.js';
|
||||
import { parseSSEBuffer } from '../cli/commands/watch.js';
|
||||
import { findAddressedOpenTask, type AgentContext } from '../cli/commands/start.js';
|
||||
import {
|
||||
listTasks,
|
||||
getTask,
|
||||
createTask,
|
||||
claimTask,
|
||||
assignTask,
|
||||
reviewTask,
|
||||
reopenTask,
|
||||
doneTask,
|
||||
} from '../core/services/taskService.js';
|
||||
import { createHandoff, getHandoff } from '../core/services/handoffService.js';
|
||||
import { addMemory, searchMemory } from '../core/services/memoryService.js';
|
||||
import { createDecision } from '../core/services/decisionService.js';
|
||||
import { getStatus } from '../core/services/statusService.js';
|
||||
|
||||
/**
|
||||
* AgentHub MCP server (TSK-0030).
|
||||
*
|
||||
* Exposes the hub operations as structured MCP tools instead of CLI strings —
|
||||
* so an agent invokes a typed tool (it can't narrate it away, hallucinate a
|
||||
* missing command, or get the syntax wrong). It's a thin layer over the SAME
|
||||
* core: when a hub server is configured/reachable it proxies through the REST
|
||||
* API (remoteClient) — so the board, SSE stream, status auto-refresh and CLI
|
||||
* all keep working unchanged — otherwise it falls back to the local services.
|
||||
*
|
||||
* Transport: stdio (each agent's CLI spawns `agenthub mcp` as a subprocess).
|
||||
*/
|
||||
function resolveContext(cwd: string): { root: string; serverUrl?: string } {
|
||||
const root = findProjectRoot(cwd) ?? cwd;
|
||||
let serverUrl = process.env.AGENTHUB_SERVER || undefined;
|
||||
if (!serverUrl) {
|
||||
try {
|
||||
serverUrl = loadConfig(root).serverUrl;
|
||||
} catch {
|
||||
// no project config — local/none
|
||||
}
|
||||
}
|
||||
return { root, serverUrl };
|
||||
}
|
||||
|
||||
function asText(value: unknown) {
|
||||
return { content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] };
|
||||
}
|
||||
|
||||
/** Block on the SSE stream until findClaim() returns a task, or timeout. */
|
||||
function waitForTask<T>(serverUrl: string, findClaim: () => Promise<T | null>, timeoutSec: number): Promise<T | null> {
|
||||
return new Promise((resolve) => {
|
||||
const controller = new AbortController();
|
||||
let settled = false;
|
||||
const finish = (v: T | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { controller.abort(); } catch { /* already */ }
|
||||
resolve(v);
|
||||
};
|
||||
const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000);
|
||||
|
||||
fetch(new URL('/events', serverUrl).toString(), { signal: controller.signal, headers: { Accept: 'text/event-stream' } })
|
||||
.then(async (res) => {
|
||||
if (!res.body) { clearTimeout(timer); finish(null); return; }
|
||||
// Close the gap: a task may have arrived between the initial check and now.
|
||||
const early = await findClaim();
|
||||
if (early) { clearTimeout(timer); finish(early); return; }
|
||||
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 === 'task')) {
|
||||
const claimed = await findClaim();
|
||||
if (claimed) { clearTimeout(timer); finish(claimed); return; }
|
||||
}
|
||||
}
|
||||
clearTimeout(timer); finish(null);
|
||||
})
|
||||
.catch(() => { clearTimeout(timer); finish(null); });
|
||||
});
|
||||
}
|
||||
|
||||
export async function startMcpServer(cwd: string): Promise<void> {
|
||||
const { root, serverUrl } = resolveContext(cwd);
|
||||
const remote = !!serverUrl;
|
||||
const server = new McpServer({ name: 'agenthub', version: '0.7.0' });
|
||||
|
||||
server.tool('agenthub_hello', 'Announce yourself to the hub (presence). Run once when you start.',
|
||||
{ agent: z.string(), role: z.string().optional() },
|
||||
async ({ agent, role }) => {
|
||||
if (remote) await remoteClient.announce(serverUrl!, agent, role ?? 'implementer');
|
||||
return asText(`AgentHub: ${agent} joined (${role ?? 'implementer'})`);
|
||||
});
|
||||
|
||||
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.',
|
||||
{ agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional() },
|
||||
async ({ agent, role, timeoutSec }) => {
|
||||
const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' };
|
||||
const findClaim = async () => {
|
||||
const found = await findAddressedOpenTask(ctx);
|
||||
if (!found) return null;
|
||||
if (remote) await remoteClient.claimTask(serverUrl!, found.task.id, agent);
|
||||
else claimTask(root, found.task.id, agent);
|
||||
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);
|
||||
let handoff: unknown = null;
|
||||
if (hofEntry) {
|
||||
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 };
|
||||
};
|
||||
const immediate = await findClaim();
|
||||
if (immediate) return asText(immediate);
|
||||
if (!remote) return asText(`No open task addressed to ${agent}, and no hub server to wait on.`);
|
||||
const claimed = await waitForTask(serverUrl!, findClaim, timeoutSec ?? 300);
|
||||
return asText(claimed ?? `No task for ${agent} within ${timeoutSec ?? 300}s — call agenthub_work again.`);
|
||||
});
|
||||
|
||||
server.tool('agenthub_task_list', 'List tasks, optionally filtered by status and/or role.',
|
||||
{ status: z.string().optional(), role: z.string().optional() },
|
||||
async ({ status, role }) => asText(remote ? await remoteClient.listTasks(serverUrl!, { status, role }) : listTasks(root, { status, role })));
|
||||
|
||||
server.tool('agenthub_task_show', 'Show one task with its full body.',
|
||||
{ id: z.string() },
|
||||
async ({ id }) => asText(remote ? await remoteClient.getTask(serverUrl!, id) : (() => { const t = getTask(root, id); return { task: t.task, body: t.body }; })()));
|
||||
|
||||
server.tool('agenthub_task_create', 'Create a task.',
|
||||
{
|
||||
title: z.string(),
|
||||
role: z.enum(['architect', 'implementer', 'reviewer', 'tester']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
|
||||
},
|
||||
async (o) => asText(remote ? await remoteClient.createTask(serverUrl!, o) : createTask(root, o)));
|
||||
|
||||
server.tool('agenthub_task_assign', 'Address an open task to an agent without claiming it (architect). The agent\'s agenthub_work then auto-claims it.',
|
||||
{ id: z.string(), agent: z.string() },
|
||||
async ({ id, agent }) => asText(remote ? await remoteClient.assignTask(serverUrl!, id, agent) : assignTask(root, id, agent)));
|
||||
|
||||
server.tool('agenthub_task_claim', 'Claim a task (set in_progress + assignedTo).',
|
||||
{ id: z.string(), agent: z.string() },
|
||||
async ({ id, agent }) => asText(remote ? await remoteClient.claimTask(serverUrl!, id, agent) : claimTask(root, id, agent)));
|
||||
|
||||
server.tool('agenthub_task_review', 'Submit a finished task for architect review. Implementers use THIS, never agenthub_task_done.',
|
||||
{ id: z.string() },
|
||||
async ({ id }) => asText(remote ? await remoteClient.reviewTask(serverUrl!, id) : reviewTask(root, id)));
|
||||
|
||||
server.tool('agenthub_task_reopen', 'Re-trigger a task after review (architect: send back to the implementer).',
|
||||
{ id: z.string() },
|
||||
async ({ id }) => asText(remote ? await remoteClient.reopenTask(serverUrl!, id) : reopenTask(root, id)));
|
||||
|
||||
server.tool('agenthub_task_done', 'Approve and close a task. ARCHITECT ONLY — implementers must use agenthub_task_review.',
|
||||
{ id: z.string() },
|
||||
async ({ id }) => asText(remote ? await remoteClient.doneTask(serverUrl!, id, {}) : doneTask(root, id)));
|
||||
|
||||
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() },
|
||||
async (o) => asText(remote ? await remoteClient.addMemory(serverUrl!, o) : addMemory(root, o)));
|
||||
|
||||
server.tool('agenthub_memory_search', 'Search memory and tasks.',
|
||||
{ q: z.string() },
|
||||
async ({ q }) => asText(remote ? await remoteClient.searchMemory(serverUrl!, q) : searchMemory(root, q)));
|
||||
|
||||
server.tool('agenthub_handoff_read', 'Read a handoff (scope + context for a task).',
|
||||
{ id: z.string() },
|
||||
async ({ id }) => asText(remote ? await remoteClient.getHandoff(serverUrl!, id) : (() => { const h = getHandoff(root, id); return { handoff: h.handoff, body: h.body }; })()));
|
||||
|
||||
server.tool('agenthub_handoff_create', 'Create a handoff (architect delegates / gives feedback).',
|
||||
{ fromRole: z.string(), toRole: z.string(), taskId: z.string().optional(), summary: z.string(), context: z.string().optional() },
|
||||
async (o) => asText(remote ? await remoteClient.createHandoff(serverUrl!, o) : createHandoff(root, o)));
|
||||
|
||||
server.tool('agenthub_decision_create', 'Record an architecture/technical decision.',
|
||||
{ title: z.string(), context: z.string().optional(), decision: z.string() },
|
||||
async (o) => asText(remote ? await remoteClient.createDecision(serverUrl!, o) : createDecision(root, o)));
|
||||
|
||||
server.tool('agenthub_status', 'Show the current project status.',
|
||||
{},
|
||||
async () => asText(remote ? await remoteClient.getStatus(serverUrl!) : getStatus(root)));
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
// stdio servers must not write to stdout (it's the protocol channel); log to stderr.
|
||||
process.stderr.write(`AgentHub MCP server ready (${remote ? `hub ${serverUrl}` : `local ${root}`}).\n`);
|
||||
}
|
||||
@ -8,7 +8,6 @@ import { getStatus, updateStatus } from '../core/services/statusService.js';
|
||||
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
|
||||
import { loadConfig } from '../core/config.js';
|
||||
import { renderBoardHtml } from './board.js';
|
||||
import { renderTeamHtml } from './team.js';
|
||||
import { eventBus, emitChange } from './events.js';
|
||||
import type { AgentHubEvent } from './events.js';
|
||||
import type { Task, Handoff, Decision, Memory } from '../core/schema.js';
|
||||
@ -28,12 +27,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
const boardHtml = renderBoardHtml();
|
||||
app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml));
|
||||
|
||||
// Team hierarchy page: roles tree with per-agent free/busy state.
|
||||
app.get('/team', async (_request, reply) => {
|
||||
const teamHtml = renderTeamHtml(cwd);
|
||||
return reply.type('text/html; charset=utf-8').send(teamHtml);
|
||||
});
|
||||
|
||||
// ─── Server-Sent Events ──────────────────────────────────────────────────
|
||||
// GET /events?role=<role>
|
||||
//
|
||||
|
||||
@ -1,258 +0,0 @@
|
||||
/**
|
||||
* Team hierarchy page, served at `GET /team`.
|
||||
*
|
||||
* Renders a role tree (architect on top, implementer/tester below) with one
|
||||
* card per configured agent. Free/busy state is derived from in-progress tasks.
|
||||
*/
|
||||
|
||||
import { loadConfig } from '../core/config.js';
|
||||
import { listTasks } from '../core/services/taskService.js';
|
||||
import {
|
||||
agentAvatar,
|
||||
designTokensCss,
|
||||
escapeHtml,
|
||||
liveTimerJs,
|
||||
pageHeader,
|
||||
statusPill,
|
||||
} from './ui-shared.js';
|
||||
import type { Task } from '../core/schema.js';
|
||||
type TaskStatus = Task['status'];
|
||||
|
||||
interface AgentView {
|
||||
name: string;
|
||||
role: string;
|
||||
isArchitect: boolean;
|
||||
busyTask?: Task;
|
||||
}
|
||||
|
||||
const ROLE_ORDER: Record<string, number> = {
|
||||
architect: 0,
|
||||
implementer: 1,
|
||||
reviewer: 2,
|
||||
tester: 3,
|
||||
};
|
||||
|
||||
function sortRoles(roles: string[]): string[] {
|
||||
return [...roles].sort((a, b) => {
|
||||
const oa = ROLE_ORDER[a] ?? 99;
|
||||
const ob = ROLE_ORDER[b] ?? 99;
|
||||
return oa - ob;
|
||||
});
|
||||
}
|
||||
|
||||
function gatherAgents(cwd: string): AgentView[] {
|
||||
const config = loadConfig(cwd);
|
||||
const byRole = new Map<string, string[]>();
|
||||
|
||||
for (const [role, cfg] of Object.entries(config.roles)) {
|
||||
const agents = byRole.get(role) ?? [];
|
||||
if (!agents.includes(cfg.preferredAgent)) {
|
||||
agents.push(cfg.preferredAgent);
|
||||
}
|
||||
byRole.set(role, agents);
|
||||
}
|
||||
|
||||
// Also surface agents that currently have tasks assigned, even if not in config.
|
||||
const tasks = listTasks(cwd) as Array<{
|
||||
id: string;
|
||||
status: TaskStatus;
|
||||
assignedTo?: string;
|
||||
role?: string;
|
||||
}>;
|
||||
for (const t of tasks) {
|
||||
if (!t.assignedTo || !t.role) continue;
|
||||
const agents = byRole.get(t.role) ?? [];
|
||||
if (!agents.includes(t.assignedTo)) {
|
||||
agents.push(t.assignedTo);
|
||||
byRole.set(t.role, agents);
|
||||
}
|
||||
}
|
||||
|
||||
const agents: AgentView[] = [];
|
||||
for (const role of sortRoles(Array.from(byRole.keys()))) {
|
||||
const names = byRole.get(role) ?? [];
|
||||
for (const name of names.sort((a, b) => a.localeCompare(b))) {
|
||||
agents.push({ name, role, isArchitect: role === 'architect' });
|
||||
}
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
function attachBusyTasks(cwd: string, agents: AgentView[]): AgentView[] {
|
||||
const tasks = listTasks(cwd) as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
status: TaskStatus;
|
||||
assignedTo?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
|
||||
const busyByAgent = new Map<string, Task>();
|
||||
for (const t of tasks) {
|
||||
if (t.status === 'in_progress' && t.assignedTo) {
|
||||
const existing = busyByAgent.get(t.assignedTo);
|
||||
if (!existing || t.updatedAt > existing.updatedAt) {
|
||||
busyByAgent.set(t.assignedTo, t as Task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return agents.map((a) => ({
|
||||
...a,
|
||||
busyTask: busyByAgent.get(a.name),
|
||||
}));
|
||||
}
|
||||
|
||||
function renderAgentCard(agent: AgentView): string {
|
||||
const busy = agent.busyTask;
|
||||
const statusDot = busy
|
||||
? `<span style="width:8px;height:8px;border-radius:50%;background:var(--status-in_progress);"></span> busy`
|
||||
: `<span style="width:8px;height:8px;border-radius:50%;background:var(--green);"></span> free`;
|
||||
|
||||
const busyLine = busy
|
||||
? `<div style="margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
${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 moments ago</a>
|
||||
</div>`
|
||||
: `<div style="margin-top:8px;display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px;">
|
||||
${statusDot}
|
||||
</div>`;
|
||||
|
||||
return `
|
||||
<div class="agent-card" style="
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
min-width: 220px;
|
||||
position: relative;
|
||||
">
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
${agentAvatar(agent.name, { architectRing: agent.isArchitect, size: 32 })}
|
||||
<div style="min-width:0;">
|
||||
<div style="font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(agent.name)}</div>
|
||||
<div style="color:var(--muted);font-size:11px;text-transform:capitalize;">${escapeHtml(agent.role)}</div>
|
||||
</div>
|
||||
</div>
|
||||
${busyLine}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderRoleGroup(role: string, agents: AgentView[]): string {
|
||||
const isArchitect = role === 'architect';
|
||||
return `
|
||||
<div class="role-group" data-role="${escapeHtml(role)}" style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
position: relative;
|
||||
">
|
||||
<div class="role-label" style="
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
background: var(--bg);
|
||||
padding: 2px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
z-index: 2;
|
||||
">${escapeHtml(role)}</div>
|
||||
<div class="agent-row" style="
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
position: relative;
|
||||
${isArchitect ? '' : 'padding-top: 18px; border-top: 1px solid var(--border); margin-top: -11px; width: 100%;'}
|
||||
">
|
||||
${agents.map(renderAgentCard).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderTeamHtml(cwd: string): string {
|
||||
const config = loadConfig(cwd);
|
||||
const agents = attachBusyTasks(cwd, gatherAgents(cwd));
|
||||
|
||||
const byRole = new Map<string, AgentView[]>();
|
||||
for (const a of agents) {
|
||||
const list = byRole.get(a.role) ?? [];
|
||||
list.push(a);
|
||||
byRole.set(a.role, list);
|
||||
}
|
||||
|
||||
const orderedRoles = sortRoles(Array.from(byRole.keys()));
|
||||
const architectGroup = orderedRoles.includes('architect')
|
||||
? renderRoleGroup('architect', byRole.get('architect')!)
|
||||
: '';
|
||||
const workerGroups = orderedRoles
|
||||
.filter((r) => r !== 'architect')
|
||||
.map((r) => renderRoleGroup(r, byRole.get(r)!))
|
||||
.join('');
|
||||
|
||||
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 Team</title>
|
||||
<style>
|
||||
${designTokensCss()}
|
||||
|
||||
.tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 36px;
|
||||
padding: 8px 0 32px;
|
||||
}
|
||||
|
||||
.worker-tier {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 36px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Connector line from architect tier down to worker tier */
|
||||
.connector-down {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
transition: border-color 150ms ease, transform 150ms ease;
|
||||
}
|
||||
.agent-card:hover {
|
||||
border-color: rgba(88, 166, 255, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.worker-tier { flex-direction: column; align-items: center; gap: 24px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${pageHeader(config.projectName, 'team')}
|
||||
|
||||
<main class="tree" id="tree">
|
||||
${architectGroup}
|
||||
${workerGroups ? `<div class="connector-down" aria-hidden="true"></div><div class="worker-tier">${workerGroups}</div>` : ''}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
${liveTimerJs()}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@ -1,243 +0,0 @@
|
||||
/**
|
||||
* Shared UI primitives for AgentHub HTML pages.
|
||||
*
|
||||
* Constraints:
|
||||
* - Dependency-free: only string/template helpers, no npm UI libs.
|
||||
* - No emojis: inline SVG/CSS only.
|
||||
* - Self-contained: pages import this and inline the returned CSS/JS.
|
||||
*/
|
||||
|
||||
import { TaskStatus as TaskStatusSchema } from '../core/schema.js';
|
||||
type TaskStatus = 'open' | 'in_progress' | 'review' | 'done' | 'cancelled';
|
||||
|
||||
/** CSS variables block matching the AgentHub dark design spec. */
|
||||
export function designTokensCss(): string {
|
||||
return `
|
||||
:root {
|
||||
--bg: #0F172A;
|
||||
--surface: #161B22;
|
||||
--raised: #1E293B;
|
||||
--border: #30363D;
|
||||
--text: #F8FAFC;
|
||||
--muted: #94A3B8;
|
||||
--accent: #58A6FF;
|
||||
--green: #22C55E;
|
||||
|
||||
--status-open: #8B949E;
|
||||
--status-in_progress: #58A6FF;
|
||||
--status-review: #D29922;
|
||||
--status-done: #22C55E;
|
||||
--status-cancelled: #6E7681;
|
||||
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.5 var(--font-sans);
|
||||
padding: 16px 20px 32px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
const AGENT_PALETTE: Record<string, { color: string; initial: string }> = {
|
||||
claude: { color: '#D97757', initial: 'C' },
|
||||
codex: { color: '#10A37F', initial: 'Cx' },
|
||||
kimi: { color: '#7C3AED', initial: 'K' },
|
||||
'windows-claude': { color: '#2563EB', initial: 'W' },
|
||||
backyard: { color: '#64748B', initial: 'B' },
|
||||
};
|
||||
|
||||
function hashString(str: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (h << 5) - h + str.charCodeAt(i);
|
||||
h |= 0;
|
||||
}
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
function deterministicColor(name: string): string {
|
||||
const colors = ['#DC2626', '#EA580C', '#D97706', '#65A30D', '#0891B2', '#2563EB', '#7C3AED', '#DB2777'];
|
||||
return colors[hashString(name) % colors.length];
|
||||
}
|
||||
|
||||
export interface AgentAvatarOptions {
|
||||
/** Render an extra ring for the architect role. */
|
||||
architectRing?: boolean;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a round agent avatar chip with per-agent accent color and initials.
|
||||
* No external images, no emojis.
|
||||
*/
|
||||
export function agentAvatar(
|
||||
name: string | undefined,
|
||||
options: AgentAvatarOptions = {},
|
||||
): string {
|
||||
const resolvedName = name?.toLowerCase() ?? '';
|
||||
const spec = AGENT_PALETTE[resolvedName] ?? {
|
||||
color: deterministicColor(resolvedName || 'unknown'),
|
||||
initial: (name ?? '?').slice(0, 1).toUpperCase(),
|
||||
};
|
||||
|
||||
const { architectRing = false, size = 22 } = options;
|
||||
const fontSize = Math.round(size * 0.45);
|
||||
const ring = architectRing
|
||||
? `box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px ${spec.color};`
|
||||
: '';
|
||||
|
||||
return `<span class="agent-avatar" title="${escapeHtml(name ?? 'unknown')}" style="
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
border-radius: 50%;
|
||||
background: ${spec.color};
|
||||
color: #fff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: ${fontSize}px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
${ring}
|
||||
">${escapeHtml(spec.initial)}</span>`;
|
||||
}
|
||||
|
||||
/** Inline status pill for a task status. */
|
||||
export function statusPill(status: TaskStatus): string {
|
||||
const labels: Record<TaskStatus, string> = {
|
||||
open: 'Open',
|
||||
in_progress: 'In Progress',
|
||||
review: 'Review',
|
||||
done: 'Done',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
const colorVar = `--status-${status}`;
|
||||
return `<span class="status-pill" data-status="${status}" style="
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
color: var(${colorVar});
|
||||
border: 1px solid var(${colorVar});
|
||||
background: transparent;
|
||||
"><span style="width:6px;height:6px;border-radius:50%;background:var(${colorVar});"></span>${escapeHtml(labels[status])}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS snippet that injects RGB versions of status colors so statusPill can use
|
||||
* rgba() backgrounds/borders. Include this once in the page <style>.
|
||||
*/
|
||||
export function statusRgbCss(): string {
|
||||
return `
|
||||
.status-pill[data-status="open"] { --status-rgb: 139, 148, 158; }
|
||||
.status-pill[data-status="in_progress"] { --status-rgb: 88, 166, 255; }
|
||||
.status-pill[data-status="review"] { --status-rgb: 210, 153, 34; }
|
||||
.status-pill[data-status="done"] { --status-rgb: 34, 197, 94; }
|
||||
.status-pill[data-status="cancelled"] { --status-rgb: 110, 118, 129; }
|
||||
`;
|
||||
}
|
||||
|
||||
/** Escape HTML entities in a string. */
|
||||
export function escapeHtml(raw: string): string {
|
||||
return raw
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side live timer snippet.
|
||||
*
|
||||
* Expects elements with `data-live-timer` and ISO timestamps in
|
||||
* `data-timer-at`. The optional `data-timer-mode` controls the label prefix:
|
||||
* - open -> "created"
|
||||
* - in_progress -> "claimed"
|
||||
* - review -> "review"
|
||||
* - done -> "done"
|
||||
*
|
||||
* Updates every second.
|
||||
*/
|
||||
export function liveTimerJs(): string {
|
||||
return `
|
||||
(function() {
|
||||
function formatAgo(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 update() {
|
||||
document.querySelectorAll('[data-live-timer]').forEach(function(el) {
|
||||
var at = el.getAttribute('data-timer-at');
|
||||
var mode = el.getAttribute('data-timer-mode') || 'open';
|
||||
var prefix = { open: 'created', in_progress: 'claimed', review: 'review', done: 'done', cancelled: 'cancelled' }[mode] || 'updated';
|
||||
el.textContent = prefix + ' ' + formatAgo(at) + ' ago';
|
||||
});
|
||||
}
|
||||
update();
|
||||
setInterval(update, 1000);
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
/** Common page header markup with AgentHub mark, project name and nav. */
|
||||
export function pageHeader(projectName: string, current: 'board' | 'team'): string {
|
||||
const mark = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="flex:0 0 auto"><circle cx="12" cy="12" r="10" stroke="var(--accent)" stroke-width="2.5"/><circle cx="12" cy="12" r="4" fill="var(--accent)"/></svg>`;
|
||||
const navItem = (label: string, path: string, active: boolean) =>
|
||||
`<a href="${path}" style="
|
||||
text-decoration: none;
|
||||
color: ${active ? 'var(--text)' : 'var(--muted)'};
|
||||
font-weight: ${active ? '600' : '400'};
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${active ? 'var(--border)' : 'transparent'};
|
||||
background: ${active ? 'var(--surface)' : 'transparent'};
|
||||
">${label}</a>`;
|
||||
|
||||
return `
|
||||
<header style="
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
">
|
||||
${mark}
|
||||
<h1 style="font-size:18px;margin:0;font-weight:600;">AgentHub</h1>
|
||||
<span style="color:var(--muted);font-size:12px;">${escapeHtml(projectName)}</span>
|
||||
<span style="flex:1;"></span>
|
||||
<nav style="display:flex;gap:8px;align-items:center;">
|
||||
${navItem('Board', '/board', current === 'board')}
|
||||
${navItem('Team', '/team', current === 'team')}
|
||||
</nav>
|
||||
<span id="conn-dot" style="width:8px;height:8px;border-radius:50%;background:var(--green);" title="connected"></span>
|
||||
</header>`;
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } 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';
|
||||
|
||||
describe('GET /team', () => {
|
||||
let cwd: string;
|
||||
let app: ReturnType<typeof buildApp>;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-team-'));
|
||||
init(cwd, { projectName: 'team-test', yes: true });
|
||||
app = buildApp(cwd);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('serves the team page as HTML', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/team' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/html');
|
||||
|
||||
const html = res.payload;
|
||||
expect(html).toContain('<title>AgentHub Team</title>');
|
||||
expect(html).toContain('team-test');
|
||||
expect(html).toContain('/board');
|
||||
expect(html).toContain('/team');
|
||||
});
|
||||
|
||||
it('renders configured agents by role', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/team' });
|
||||
const html = res.payload;
|
||||
expect(html).toContain('architect');
|
||||
expect(html).toContain('implementer');
|
||||
expect(html).toContain('claude');
|
||||
expect(html).toContain('codex');
|
||||
});
|
||||
|
||||
it('marks an agent as busy when they have an in-progress task', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/tasks',
|
||||
payload: { title: 'Busy work', role: 'implementer' },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/tasks/TSK-0001',
|
||||
payload: { status: 'in_progress', assignedTo: 'codex' },
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/team' });
|
||||
const html = res.payload;
|
||||
expect(html).toContain('TSK-0001');
|
||||
expect(html).toContain('data-live-timer');
|
||||
expect(html).toContain('claimed');
|
||||
});
|
||||
});
|
||||
@ -1,50 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { agentAvatar, designTokensCss, escapeHtml, liveTimerJs, statusPill } from '../src/server/ui-shared.js';
|
||||
|
||||
describe('ui-shared helpers', () => {
|
||||
it('designTokensCss returns the dark palette variables', () => {
|
||||
const css = designTokensCss();
|
||||
expect(css).toContain('--bg: #0F172A');
|
||||
expect(css).toContain('--status-in_progress: #58A6FF');
|
||||
expect(css).toContain('prefers-reduced-motion');
|
||||
});
|
||||
|
||||
it('agentAvatar renders initials without emojis', () => {
|
||||
const html = agentAvatar('kimi');
|
||||
expect(html).toContain('K');
|
||||
expect(html).toContain('#7C3AED');
|
||||
expect(html).not.toContain('emoji');
|
||||
});
|
||||
|
||||
it('agentAvatar renders deterministic fallback for unknown agents', () => {
|
||||
const html = agentAvatar('robo-agent-42');
|
||||
expect(html).toContain('R');
|
||||
expect(html).toContain('agent-avatar');
|
||||
});
|
||||
|
||||
it('agentAvatar adds architect ring when requested', () => {
|
||||
const html = agentAvatar('claude', { architectRing: true });
|
||||
expect(html).toContain('box-shadow');
|
||||
});
|
||||
|
||||
it('statusPill renders each known status', () => {
|
||||
for (const status of ['open', 'in_progress', 'review', 'done', 'cancelled'] as const) {
|
||||
const html = statusPill(status);
|
||||
expect(html).toContain(`data-status="${status}"`);
|
||||
expect(html).toContain('status-pill');
|
||||
}
|
||||
});
|
||||
|
||||
it('escapeHtml escapes dangerous characters', () => {
|
||||
expect(escapeHtml('<script>alert("x")</script>')).toBe(
|
||||
'<script>alert("x")</script>',
|
||||
);
|
||||
});
|
||||
|
||||
it('liveTimerJs contains the setInterval updater', () => {
|
||||
const js = liveTimerJs();
|
||||
expect(js).toContain('setInterval');
|
||||
expect(js).toContain('data-live-timer');
|
||||
expect(js).toContain('claimed');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user