diff --git a/package.json b/package.json index 12bc12f..0b17614 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agenthub", - "version": "0.7.4", + "version": "0.7.5", "description": "Local coordination layer for AI coding agents", "type": "module", "main": "./dist/index.js", diff --git a/src/cli/index.ts b/src/cli/index.ts index 2f5ce13..5f50ecc 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -116,7 +116,7 @@ async function runRemote(serverUrl: string, fn: () => Promise): Promise', 'AgentHub server URL (env: AGENTHUB_SERVER)'); program diff --git a/src/server/team.ts b/src/server/team.ts index 98cf74d..014eef5 100644 --- a/src/server/team.ts +++ b/src/server/team.ts @@ -1,8 +1,13 @@ /** * 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. + * Roster-driven: roles, model + provider come from `config.agents` (the named + * team roster) — NOT inferred from whichever tasks an agent touched. So each + * agent has exactly one role (no more "codex in three roles"), demo/throwaway + * agents that aren't in the roster never show, and the model behind each agent + * is explicit. Implementers are grouped by provider (Anthropic / OpenAI / + * Moonshot) with the official brand logo. Free/busy is derived from in-progress + * tasks. Falls back to the role config for projects without a roster. */ import { loadConfig } from '../core/config.js'; @@ -13,187 +18,162 @@ import { escapeHtml, liveTimerJs, pageHeader, + providerLogo, + providerMeta, statusPill, } from './ui-shared.js'; import type { Task } from '../core/schema.js'; -type TaskStatus = Task['status']; -interface AgentView { +/** The task fields the index actually carries + that this page needs. */ +type TaskRow = Pick; + +interface RosterAgent { name: string; role: string; - isArchitect: boolean; - busyTask?: Task; + model?: string; + kind?: string; + description?: string; + busyTask?: TaskRow; } -const ROLE_ORDER: Record = { - architect: 0, - implementer: 1, - reviewer: 2, - tester: 3, -}; +const ROLE_ORDER: Record = { architect: 0, implementer: 1, reviewer: 2, tester: 3 }; +const PROVIDER_ORDER: Record = { anthropic: 0, openai: 1, moonshot: 2 }; -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[] { +/** Build the roster from config.agents, or fall back to the role config. */ +function gatherRoster(cwd: string): RosterAgent[] { const config = loadConfig(cwd); - const byRole = new Map(); - - 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); + 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, + description: a.description, + })); } - - // 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; + // Fallback: no roster configured — derive one agent per role from config.roles. + return Object.entries(config.roles).map(([role, cfg]) => ({ name: cfg.preferredAgent, role })); } -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(); +function attachBusy(cwd: string, roster: RosterAgent[]): RosterAgent[] { + const tasks = listTasks(cwd) as TaskRow[]; + const busyByAgent = new Map(); 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); - } + if (!existing || t.updatedAt > existing.updatedAt) busyByAgent.set(t.assignedTo, t); } } - - return agents.map((a) => ({ - ...a, - busyTask: busyByAgent.get(a.name), - })); + return roster.map((a) => ({ ...a, busyTask: busyByAgent.get(a.name) })); } -function renderAgentCard(agent: AgentView): string { - const busy = agent.busyTask; - const statusDot = busy - ? ` busy` - : ` free`; +function statusLine(a: RosterAgent): string { + const busy = a.busyTask; + if (busy) { + return `
+ ${statusPill('in_progress')} + claimed +
`; + } + return `
+ free +
`; +} - const busyLine = busy - ? `
- ${statusPill('in_progress')} - claimed moments ago -
` - : `
- ${statusDot} -
`; +/** + * Render one agent card. `avatar:'logo'` shows the provider brand mark (for + * standalone tiers); `avatar:'monogram'` shows the per-agent initials chip (used + * inside a provider group whose header already carries the brand logo). + */ +function agentCard(a: RosterAgent, opts: { avatar: 'logo' | 'monogram'; architectRing?: boolean } = { avatar: 'monogram' }): string { + const meta = providerMeta(a.kind); + const avatar = + opts.avatar === 'logo' && meta + ? `${providerLogo(a.kind, 19)}` + : agentAvatar(a.name, { architectRing: opts.architectRing, size: 34 }); + + const model = a.model + ? `${escapeHtml(a.model)}` + : ''; return ` -
+
- ${agentAvatar(agent.name, { architectRing: agent.isArchitect, size: 32 })} + ${avatar}
-
${escapeHtml(agent.name)}
-
${escapeHtml(agent.role)}
+
${escapeHtml(a.name)}
+ ${model}
- ${busyLine} + ${a.description ? `
${escapeHtml(a.description)}
` : ''} + ${statusLine(a)}
`; } -function renderRoleGroup(role: string, agents: AgentView[]): string { - const isArchitect = role === 'architect'; +/** A provider column: brand-logo header + the agents running on that provider. */ +function providerGroup(kind: string, agents: RosterAgent[]): string { + const meta = providerMeta(kind); + const busy = agents.filter((a) => a.busyTask).length; return ` -
-
${escapeHtml(role)}
-
- ${agents.map(renderAgentCard).join('')} +
+
+ ${providerLogo(kind, 18)} + ${meta ? escapeHtml(meta.name) : escapeHtml(kind)} + + ${busy}/${agents.length} busy
+ ${agents.map((a) => agentCard(a, { avatar: 'monogram' })).join('')}
`; } +function tierLabel(text: string): string { + return `
${escapeHtml(text)}
`; +} + export function renderTeamHtml(cwd: string): string { const config = loadConfig(cwd); - const agents = attachBusyTasks(cwd, gatherAgents(cwd)); + const roster = attachBusy(cwd, gatherRoster(cwd)); - const byRole = new Map(); - for (const a of agents) { + const byRole = new Map(); + for (const a of roster) { 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 architects = (byRole.get('architect') ?? []).sort((a, b) => a.name.localeCompare(b.name)); + const implementers = byRole.get('implementer') ?? []; + const testers = (byRole.get('tester') ?? []).sort((a, b) => a.name.localeCompare(b.name)); + + // Group implementers by provider, ordered anthropic -> openai -> moonshot -> rest. + const byProvider = new Map(); + for (const a of implementers) { + const key = a.kind ?? 'other'; + const list = byProvider.get(key) ?? []; + list.push(a); + byProvider.set(key, list); + } + const providerKeys = Array.from(byProvider.keys()).sort( + (a, b) => (PROVIDER_ORDER[a] ?? 99) - (PROVIDER_ORDER[b] ?? 99) || a.localeCompare(b), + ); + for (const k of byProvider.keys()) { + byProvider.get(k)!.sort((a, b) => a.name.localeCompare(b.name)); + } + + const architectTier = architects.length + ? `
${architects.map((a) => agentCard(a, { avatar: 'logo', architectRing: true })).join('')}
${tierLabel('Architect')}
` : ''; - const workerGroups = orderedRoles - .filter((r) => r !== 'architect') - .map((r) => renderRoleGroup(r, byRole.get(r)!)) - .join(''); + + const implementerTier = providerKeys.length + ? `
${tierLabel('Implementers')}
${providerKeys.map((k) => providerGroup(k, byProvider.get(k)!)).join('')}
` + : ''; + + const testerTier = testers.length + ? `
${tierLabel('Testers')}
${testers.map((a) => agentCard(a, { avatar: 'logo' })).join('')}
` + : ''; + + const connector = ''; + const tiers = [architectTier, implementerTier, testerTier].filter(Boolean).join(connector); return ` @@ -205,51 +185,27 @@ export function renderTeamHtml(cwd: string): string { ${pageHeader(config.projectName, 'team')} -
- ${architectGroup} - ${workerGroups ? `
${workerGroups}
` : ''} + ${tiers}
- diff --git a/src/server/ui-shared.ts b/src/server/ui-shared.ts index 837f603..ec1ee36 100644 --- a/src/server/ui-shared.ts +++ b/src/server/ui-shared.ts @@ -114,6 +114,42 @@ export function agentAvatar( ">${escapeHtml(spec.initial)}`; } +/** + * Provider/company brand metadata + official Simple Icons SVG paths (verified, + * 24x24 viewBox). Used to group + badge agents by the model behind them. + */ +const PROVIDERS: Record = { + anthropic: { + name: 'Anthropic', + color: '#D97757', + path: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z', + }, + openai: { + name: 'OpenAI', + color: '#10A37F', + path: 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z', + }, + moonshot: { + name: 'Moonshot', + color: '#7C3AED', + path: 'm1.053 16.91 9.538 2.55a21 20.981 0 0 0 .06 2.031l5.956 1.592a12 11.99 0 0 1-15.554-6.172m-1.02-5.79 11.352 3.035a21 20.981 0 0 0-.469 2.01l10.817 2.89a12 11.99 0 0 1-1.845 2.004L.658 15.918a12 11.99 0 0 1-.625-4.796m1.593-5.146L13.573 9.17a21 20.981 0 0 0-1.01 1.874l11.297 3.02a21 20.981 0 0 1-.67 2.362l-11.55-3.087L.125 10.26a12 11.99 0 0 1 1.499-4.285ZM6.067 1.58l11.285 3.016a21 20.981 0 0 0-1.688 1.719l7.824 2.091a21 20.981 0 0 1 .513 2.664L2.107 5.218a12 11.99 0 0 1 3.96-3.638M21.68 4.866 7.222 1.003A12 11.99 0 0 1 21.68 4.866', + }, +}; + +/** Brand metadata for a provider kind, or null if unknown. */ +export function providerMeta(kind?: string): { name: string; color: string } | null { + if (!kind) return null; + const p = PROVIDERS[kind]; + return p ? { name: p.name, color: p.color } : null; +} + +/** Inline brand logo SVG for a provider kind, tinted with its brand color. */ +export function providerLogo(kind: string | undefined, size = 20): string { + const p = kind ? PROVIDERS[kind] : undefined; + if (!p) return ''; + return ``; +} + /** Inline status pill for a task status. */ export function statusPill(status: TaskStatus): string { const labels: Record = {