diff --git a/package.json b/package.json index 1be18da..b88744f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@inquirer/prompts": "^7.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "better-sqlite3": "^12.0.0", + "bonjour-service": "^1.4.2", "commander": "^13.0.0", "fastify": "^5.0.0", "yaml": "^2.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec36782..4f462e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: better-sqlite3: specifier: ^12.0.0 version: 12.11.1 + bonjour-service: + specifier: ^1.4.2 + version: 1.4.2 commander: specifier: ^13.0.0 version: 13.1.0 @@ -362,6 +365,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@leichtgewicht/ip-codec@2.0.5': + resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -598,6 +604,9 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} + bonjour-service@1.4.2: + resolution: {integrity: sha512-lMskhnsW70yWHr4PhPeh2rvaIkLSaDpp+nmtbXBZaNKTXwxL73QOkW6HhbzqTImXjevn9TreGT4GACGBCGP9nQ==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -711,6 +720,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dns-packet@5.6.1: + resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==} + engines: {node: '>=6'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -967,6 +980,10 @@ packages: ms@2.1.3: 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: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -1245,6 +1262,9 @@ packages: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} + thunky@1.1.0: + resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1642,6 +1662,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@leichtgewicht/ip-codec@2.0.5': {} + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.27) @@ -1864,6 +1886,11 @@ snapshots: transitivePeerDependencies: - supports-color + bonjour-service@1.4.2: + dependencies: + fast-deep-equal: 3.1.3 + multicast-dns: 7.2.5 + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -1948,6 +1975,10 @@ snapshots: detect-libc@2.1.2: {} + dns-packet@5.6.1: + dependencies: + '@leichtgewicht/ip-codec': 2.0.5 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2238,6 +2269,11 @@ snapshots: ms@2.1.3: {} + multicast-dns@7.2.5: + dependencies: + dns-packet: 5.6.1 + thunky: 1.1.0 + mute-stream@2.0.0: {} nanoid@3.3.15: {} @@ -2559,6 +2595,8 @@ snapshots: dependencies: real-require: 1.0.0 + thunky@1.1.0: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} diff --git a/src/cli/commands/agentSetup.ts b/src/cli/commands/agentSetup.ts index 62f30eb..ce25ef0 100644 --- a/src/cli/commands/agentSetup.ts +++ b/src/cli/commands/agentSetup.ts @@ -1,23 +1,39 @@ 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 { 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. * - * Mechanism: a verified Claude Code `SessionStart` hook (.claude/settings.json) - * whose command runs `agenthub hook-context`, which prints the work-loop - * instruction. That text is injected into the new session as context. + * Every supported CLI exposes a `SessionStart` lifecycle hook whose command runs + * `agenthub hook-context`, which prints the work-loop instruction. That text is + * 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 - * NUDGE the model reads, not a hard directive it must obey — so the guide - * reinforces it. It also can't block startup. Codex/Kimi aren't Claude Code; - * their guide (CODEX.md / KIMI.md) carries the same "call agenthub_work first" - * nudge. + * Honest limits: SessionStart context is a strong NUDGE the model reads, not a + * hard directive it must obey. Codex fires the hook in interactive TUI sessions + * from the global config; Kimi fires the command on SessionStart but its docs + * don't confirm stdout is injected as context — so for Kimi the guide (KIMI.md) + * 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. */ export function hookContext(opts: { agent: string; role: string }): void { 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 dir = join(base, '.claude'); 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 }); writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); - const out = (s: string) => process.stdout.write(s + '\n'); - out(`AgentHub: auto-start hook written -> ${settingsPath}`); + out(`AgentHub: Claude Code 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(' Note: SessionStart context is a strong nudge, not a hard guarantee — the guide reinforces it.'); - out(' Codex/Kimi are not Claude Code: their guide already nudges agenthub_work on first turn.'); + out(' Note: SessionStart context is a strong nudge, not a hard guarantee.'); +} + +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); } diff --git a/src/cli/commands/start.ts b/src/cli/commands/start.ts index 85d4ae4..b27d162 100644 --- a/src/cli/commands/start.ts +++ b/src/cli/commands/start.ts @@ -41,6 +41,23 @@ async function listOpenRoleTasks(ctx: AgentContext): Promise { : 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 { + 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 * with ":" (delegation convention), OR a handoff for the task has diff --git a/src/cli/index.ts b/src/cli/index.ts index 13df86d..8b0eb97 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -498,12 +498,13 @@ export function createProgram(cwd: string): Command { const agentCmd = new Command('agent').description('Per-agent machine setup'); agentCmd .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 ', 'This machine\'s agent name') .requiredOption('--role ', 'This agent\'s role (implementer / architect / tester)') - .option('--user', 'Install for all projects (~/.claude) instead of just this one') - .action((options: { agent: string; role: string; user?: boolean }) => { - agentSetup(cwd, { agent: options.agent, role: options.role, user: options.user }); + .option('--cli ', 'CLI to target: claude | codex | kimi (default: inferred from the agent name)') + .option('--user', 'Claude Code only: install for all projects (~/.claude) instead of just this one') + .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); diff --git a/src/core/schema.ts b/src/core/schema.ts index 47c6595..722f882 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -124,6 +124,8 @@ export const AgentConfigSchema = z.object({ /** Provider/company for the logo: "anthropic" | "openai" | "moonshot" | … */ kind: 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({ diff --git a/src/core/services/budgetService.ts b/src/core/services/budgetService.ts new file mode 100644 index 0000000..8c2c6d5 --- /dev/null +++ b/src/core/services/budgetService.ts @@ -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(); + for (const r of roster) byName.set(r.name, r); + + const acc = new Map(); + 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(), + }; +} diff --git a/src/core/services/rosterService.ts b/src/core/services/rosterService.ts new file mode 100644 index 0000000..6e5ef45 --- /dev/null +++ b/src/core/services/rosterService.ts @@ -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(); + for (const [, cfg] of Object.entries(config.roles)) names.add(cfg.preferredAgent); + const roleByAgent = new Map(); + 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) }; + }); +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 185cb5a..e797f04 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -5,7 +5,7 @@ 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 { findAddressedOpenTask, listReviewTasks, isReviewerRole, type AgentContext } from '../cli/commands/start.js'; import { listTasks, getTask, @@ -105,10 +105,11 @@ export async function startMcpServer(cwd: string): Promise { }); 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() }, async ({ agent, role, timeoutSec }) => { 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 // them once and doesn't spin on the same message. const drainInbox = async () => { @@ -135,18 +136,43 @@ export async function startMcpServer(cwd: string): Promise { if (messages.length) return { claimed: null, messages, note: 'No task addressed to you, but you have messages — reply with agenthub_message.' }; 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 | null> = reviewer ? findReview : findWork; + // Self-perpetuating loop: every response reminds the agent to relaunch // agenthub_work, so a finished task/message never leaves it dormant. - const LOOP = - '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 findWork(); + const LOOP = reviewer + ? 'STAY REACHABLE: the instant you finish reviewing, call agenthub_work again — ' + + 'run it in the background so the next review submission reaches you in realtime. ' + + 'Never end your turn without relaunching agenthub_work.' + : '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 (!remote) return asText(`No open task addressed to ${agent}, and no hub server to wait on.`); - const result = await waitForTask(serverUrl!, findWork, timeoutSec ?? 300); + const emptyMsg = reviewer + ? `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 }); - 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.', diff --git a/src/server/board.ts b/src/server/board.ts index c5aa771..cad44a2 100644 --- a/src/server/board.ts +++ b/src/server/board.ts @@ -434,6 +434,220 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { .sse-status { margin-left: auto; } .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) { *, *::before, *::after { transition-duration: 0.01ms !important; @@ -464,6 +678,9 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string { Activity Decisions + connecting @@ -471,6 +688,29 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
+
+
+ + + + +
Tip: start the title with an agent name (e.g. codex: …) to address it — moving the card to In Progress then auto-assigns it. Or drag an agent onto a card to assign manually.
+
+
+ +
+
Active tasks
0
@@ -485,8 +725,19 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
${columnSkeleton()}
+ +
+
+

Cost & Budget

+ estimated from time-on-task + +
+
no agent activity yet
+
+
+ diff --git a/src/server/index.ts b/src/server/index.ts index d2367b6..e78b1db 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,7 @@ import Fastify from 'fastify'; import { registerRoutes } from './routes.js'; import { resolveAdvertiseUrl, startDiscoveryBroadcaster } from '../discovery.js'; +import { startMdnsAdvertise, type MdnsHandle } from './mdns.js'; import { startEntityWatcher } from './fsWatch.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'; let broadcaster: ReturnType | undefined; + let mdns: MdnsHandle | undefined; // Emit SSE events for CLI/file writes too, not just REST mutations. const stopWatcher = startEntityWatcher(cwd); // Keep status/latest.md fresh on every change so agents never read a stale snapshot. const stopStatusRefresh = startStatusAutoRefresh(cwd); app.addHook('onClose', async () => { broadcaster?.stop(); + mdns?.stop(); stopWatcher(); stopStatusRefresh(); }); @@ -36,6 +39,13 @@ export async function startServer(cwd: string, options: { port?: number; host?: const advertiseUrl = resolveAdvertiseUrl(host, actualPort); 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 }; } catch (err) { const e = err as NodeJS.ErrnoException; diff --git a/src/server/mdns.ts b/src/server/mdns.ts new file mode 100644 index 0000000..3a294f8 --- /dev/null +++ b/src/server/mdns.ts @@ -0,0 +1,52 @@ +import { Bonjour, type Service } from 'bonjour-service'; + +/** + * Advertise the hub over mDNS/Bonjour as `.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; + } +} diff --git a/src/server/routes.ts b/src/server/routes.ts index 915f764..99b370d 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -7,6 +7,8 @@ import { createMessage, listMessages, listInbox, markMessageRead } from '../core import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js'; import { getStatus, updateStatus } from '../core/services/statusService.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 { renderActivityHtml } from './activity.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.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 ─────────────────────────────────────────────────────────────── app.get('/tasks', async (request) => { const { status, role } = request.query as { status?: string; role?: string }; diff --git a/src/server/team.ts b/src/server/team.ts index 5fc282a..a5ed60b 100644 --- a/src/server/team.ts +++ b/src/server/team.ts @@ -70,14 +70,9 @@ function attachBusy(cwd: string, roster: RosterAgent[]): RosterAgent[] { function statusLine(a: RosterAgent): string { const busy = a.busyTask; if (busy) { - return `
- ${statusPill('in_progress')} - claimed -
`; + return ``; } - return `
- free -
`; + return `
free
`; } /** @@ -97,7 +92,7 @@ function agentCard(a: RosterAgent, opts: { avatar: 'logo' | 'monogram'; architec : ''; return ` -
+
${avatar}
@@ -120,7 +115,7 @@ function providerGroup(kind: string, agents: RosterAgent[]): string { ${providerLogo(kind, 18)} ${meta ? escapeHtml(meta.name) : escapeHtml(kind)} - ${busy}/${agents.length} busy + ${busy}/${agents.length} busy
${agents.map((a) => agentCard(a, { avatar: 'monogram' })).join('')}
`; @@ -190,10 +185,51 @@ export function renderTeamHtml(cwd: string): string { .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-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); } + /* 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) { .provider-row { flex-direction:column; align-items:center; gap:14px; } @@ -207,7 +243,81 @@ export function renderTeamHtml(cwd: string): string { ${tiers} `; diff --git a/tests/server.test.ts b/tests/server.test.ts index 60783d9..65ec062 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -196,7 +196,8 @@ describe('server routes', () => { expect(html).toContain('data-id'); expect(html).toContain('href="/tasks/'); 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'"); }); diff --git a/tests/team.test.ts b/tests/team.test.ts index 6249876..ca50352 100644 --- a/tests/team.test.ts +++ b/tests/team.test.ts @@ -55,7 +55,9 @@ describe('GET /team', () => { const res = await app.inject({ method: 'GET', url: '/team' }); const html = res.payload; 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'); }); });