Compare commits
19 Commits
caa009b66a
...
1bb18d96b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb18d96b5 | ||
|
|
c2939966b5 | ||
|
|
0f11714873 | ||
|
|
55efae028a | ||
|
|
b097ad71de | ||
|
|
38bea3b594 | ||
|
|
92a5b63954 | ||
|
|
2140a293ed | ||
|
|
0d79e8fbd6 | ||
|
|
722c8598e9 | ||
|
|
ab35389f9c | ||
|
|
ef957bc2f6 | ||
|
|
49bc39d42d | ||
|
|
8dbbd0d10c | ||
|
|
7ca6c859f2 | ||
|
|
1e29a7b9b9 | ||
|
|
caf60ea939 | ||
|
|
8b17baae96 | ||
|
|
10f3184ea2 |
@ -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",
|
||||
|
||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -41,6 +41,23 @@ async function listOpenRoleTasks(ctx: AgentContext): Promise<Listed[]> {
|
||||
: svcListTasks(ctx.projectCwd, { role: ctx.role, status: 'open' });
|
||||
}
|
||||
|
||||
/** True for roles that review submitted work rather than implement it. */
|
||||
export function isReviewerRole(role: string): boolean {
|
||||
const r = role.toLowerCase();
|
||||
return r === 'architect' || r === 'reviewer';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tasks awaiting review — the architect's equivalent of "addressed open tasks".
|
||||
* These are what an architect's `agenthub_work` loop should wake on, so review
|
||||
* submissions reach the architect in realtime instead of needing a manual re-arm.
|
||||
*/
|
||||
export async function listReviewTasks(ctx: AgentContext): Promise<Listed[]> {
|
||||
return ctx.serverUrl
|
||||
? await remoteClient.listTasks(ctx.serverUrl, { status: 'review' })
|
||||
: svcListTasks(ctx.projectCwd, { status: 'review' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the open task addressed to this agent. "Addressed" = task title starts
|
||||
* with "<agent>:" (delegation convention), OR a handoff for the task has
|
||||
|
||||
@ -148,7 +148,7 @@ async function fetchReviewTasks(serverUrl: string): Promise<AgentHubEvent[]> {
|
||||
*/
|
||||
export async function watchEvents(
|
||||
serverUrl: string,
|
||||
options: { once?: boolean; role?: string; awaitReview?: boolean } = {},
|
||||
options: { once?: boolean; role?: string; awaitReview?: boolean; newOnly?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const url = new URL('/events', serverUrl);
|
||||
// Pass role to the server for an additional server-side filter (saves
|
||||
@ -181,7 +181,10 @@ export async function watchEvents(
|
||||
|
||||
// --await-review: surface a submission that's ALREADY pending on connect,
|
||||
// so the architect isn't blind to reviews submitted before this watcher.
|
||||
if (options.awaitReview) {
|
||||
// --new-only suppresses this backlog check so the notifier can be re-armed
|
||||
// without instantly exiting on a still-non-empty review queue (no spin) —
|
||||
// it then fires only on the NEXT genuine task→review transition.
|
||||
if (options.awaitReview && !options.newOnly) {
|
||||
const pending = await fetchReviewTasks(serverUrl);
|
||||
if (pending.length > 0) {
|
||||
for (const ev of pending) console.log(formatEvent(ev));
|
||||
|
||||
@ -7,6 +7,7 @@ import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
||||
import { decisionCreate, decisionList } from './commands/decision.js';
|
||||
import { messageSend, inboxList } from './commands/message.js';
|
||||
import { agentSetup, hookContext } from './commands/agentSetup.js';
|
||||
import { syncOrgFromFile } from '../core/services/orgService.js';
|
||||
import { delegate } from './commands/delegate.js';
|
||||
import { serverStart } from './commands/server.js';
|
||||
import { update } from './commands/update.js';
|
||||
@ -498,15 +499,33 @@ 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 <name>', 'This machine\'s agent name')
|
||||
.requiredOption('--role <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>', '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);
|
||||
|
||||
// ─── org sync (CLAUDE.md → team structure) ───────────────────────────────
|
||||
const orgCmd = new Command('org').description('Team org-chart structure');
|
||||
orgCmd
|
||||
.command('sync')
|
||||
.description('Sync the team org chart from an `agenthub-org` block in CLAUDE.md / AGENTS.md')
|
||||
.option('--from <file>', 'Source markdown file (default: CLAUDE.md or AGENTS.md at the project root)')
|
||||
.action((options: { from?: string }) => {
|
||||
try {
|
||||
const r = syncOrgFromFile(cwd, options.from);
|
||||
console.log(`AgentHub: org synced — ${r.count} nodes from ${r.file}. /team now reflects it.`);
|
||||
} catch (err) {
|
||||
console.error(`AgentHub: org sync failed — ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
program.addCommand(orgCmd);
|
||||
|
||||
// Called by the SessionStart hook; prints the work-loop instruction as context.
|
||||
program
|
||||
.command('hook-context')
|
||||
@ -620,6 +639,7 @@ export function createProgram(cwd: string): Command {
|
||||
.option('--once', 'Exit 0 after the first event (useful as a blocking wait for agents)')
|
||||
.option('--role <role>', 'Client-side role filter (only show events for this role)')
|
||||
.option('--await-review', 'Exit when an implementer submits (task → review); architect review-queue notifier')
|
||||
.option('--new-only', 'With --await-review: fire only on NEW submissions, ignore tasks already in review on connect (re-armable without spinning)')
|
||||
.action(async (options) => {
|
||||
const { serverUrl } = await resolveContext(program, cwd);
|
||||
if (!serverUrl) {
|
||||
@ -631,6 +651,7 @@ export function createProgram(cwd: string): Command {
|
||||
once: options.once as boolean | undefined,
|
||||
role: options.role as string | undefined,
|
||||
awaitReview: options.awaitReview as boolean | undefined,
|
||||
newOnly: options.newOnly as boolean | undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -101,6 +101,8 @@ export class Index {
|
||||
}
|
||||
|
||||
search(query: string): Array<{ id: string; type: string; title: string }> {
|
||||
const ftsQuery = toFts5Phrase(query);
|
||||
if (!ftsQuery) return [];
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT e.id, e.type, e.title
|
||||
FROM search s
|
||||
@ -108,7 +110,7 @@ export class Index {
|
||||
WHERE search MATCH @query
|
||||
ORDER BY rank
|
||||
`);
|
||||
return stmt.all({ query }) as Array<{ id: string; type: string; title: string }>;
|
||||
return stmt.all({ query: ftsQuery }) as Array<{ id: string; type: string; title: string }>;
|
||||
}
|
||||
|
||||
list(type?: string, filters?: { status?: string; role?: string; assignedTo?: string }): IndexEntry[] {
|
||||
@ -162,3 +164,9 @@ export class Index {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function toFts5Phrase(query: string): string {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return '';
|
||||
return `"${trimmed.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
@ -124,8 +124,34 @@ 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(),
|
||||
});
|
||||
|
||||
/**
|
||||
* One node of the team org chart. Supports arbitrary depth via `parentId`, and
|
||||
* decouples the DISPLAY (label/title/editable) from the linked roster agent, so
|
||||
* org-only nodes (a human CEO, a Product Owner) can sit in the tree without a
|
||||
* running agent. Live state (active/reviewing) is derived from `agent`.
|
||||
*/
|
||||
export const OrgNodeSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
/** Editable display name. */
|
||||
label: z.string().min(1),
|
||||
/** Display role/title shown under the name (e.g. "CEO", "Product Owner").
|
||||
* For linked agents it falls back to the agent's roster role. */
|
||||
role: z.string().optional(),
|
||||
/** One-line "what this node does" — shown behind the info button. */
|
||||
title: z.string().optional(),
|
||||
/** Linked roster agent name (for model/provider/logo + live task state). */
|
||||
agent: z.string().optional(),
|
||||
/** Parent node id; omit/undefined for the root. */
|
||||
parentId: z.string().optional(),
|
||||
/** Provider override for the icon when there is no linked agent. */
|
||||
kind: z.string().optional(),
|
||||
});
|
||||
export type OrgNode = z.infer<typeof OrgNodeSchema>;
|
||||
|
||||
export const ConfigSchema = z.object({
|
||||
version: z.literal('1'),
|
||||
projectName: z.string().min(1),
|
||||
@ -133,6 +159,8 @@ export const ConfigSchema = z.object({
|
||||
roles: z.record(z.string(), RoleConfigSchema),
|
||||
/** Named team roster: agent name → role + model + provider. */
|
||||
agents: z.record(z.string(), AgentConfigSchema).optional(),
|
||||
/** Team org chart (arbitrary depth). When present, /team renders this tree. */
|
||||
org: z.array(OrgNodeSchema).optional(),
|
||||
serverUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
|
||||
198
src/core/services/budgetService.ts
Normal file
198
src/core/services/budgetService.ts
Normal file
@ -0,0 +1,198 @@
|
||||
import { getTask, listTasks } from './taskService.js';
|
||||
import { getRoster, inferKind, type RosterEntry } from './rosterService.js';
|
||||
|
||||
/**
|
||||
* Token & cost accounting for the board.
|
||||
*
|
||||
* Honest by construction: external coding CLIs (Claude Code / Codex / Kimi) do
|
||||
* NOT report their own token usage back to the hub, so we cannot show a metered
|
||||
* ground truth for them. Instead we combine two clearly-labelled sources:
|
||||
*
|
||||
* 1. REAL — `doneTokens` recorded on a task (via `task done --tokens`).
|
||||
* 2. EST. — an estimate derived from time-on-task: an active agent burns
|
||||
* roughly TOKENS_PER_ACTIVE_MIN while a task sits in_progress /
|
||||
* review, so the estimate grows live as work happens. Done tasks
|
||||
* without a recorded count fall back to their total active span.
|
||||
*
|
||||
* Every estimated figure is flagged `estimated:true` so the UI can mark it
|
||||
* with a "~" — no silent fabrication.
|
||||
*/
|
||||
|
||||
/** Tokens a working agent is assumed to spend per active minute (estimate). */
|
||||
export const TOKENS_PER_ACTIVE_MIN = 3000;
|
||||
|
||||
/**
|
||||
* Hard cap on ESTIMATED active minutes per task. Wall-clock time-in-state is a
|
||||
* bad proxy for actual compute: a task can sit "in review" or "done" for days
|
||||
* while the real work was minutes. Without this cap a task open for 12 days
|
||||
* would estimate ~50M tokens. A single coding task rarely exceeds ~45 min of
|
||||
* genuine agent work, so we clamp there — real recorded tokens/duration are
|
||||
* always used in preference and are never capped.
|
||||
*/
|
||||
export const MAX_ACTIVE_MIN_PER_TASK = 45;
|
||||
|
||||
/** USD → EUR display rate (rough, labelled approximate in the UI). */
|
||||
const USD_TO_EUR = 0.92;
|
||||
|
||||
/**
|
||||
* Blended USD price per 1M tokens per model family (≈ 75% input / 25% output of
|
||||
* each vendor's published rate). Deliberately conservative and clearly an
|
||||
* estimate — used only for a directional cost figure, never billing.
|
||||
*/
|
||||
const MODEL_PRICING: Array<{ match: RegExp; usdPerMTok: number }> = [
|
||||
{ match: /opus/i, usdPerMTok: 30 },
|
||||
{ match: /sonnet/i, usdPerMTok: 6 },
|
||||
{ match: /haiku/i, usdPerMTok: 1.6 },
|
||||
{ match: /gpt-?5|codex|o[34]/i, usdPerMTok: 3.4 },
|
||||
{ match: /kimi/i, usdPerMTok: 1.1 },
|
||||
];
|
||||
const DEFAULT_USD_PER_MTOK = 4;
|
||||
|
||||
function usdPerMTok(model?: string): number {
|
||||
if (!model) return DEFAULT_USD_PER_MTOK;
|
||||
for (const p of MODEL_PRICING) if (p.match.test(model)) return p.usdPerMTok;
|
||||
return DEFAULT_USD_PER_MTOK;
|
||||
}
|
||||
|
||||
function costEur(tokens: number, model?: string): number {
|
||||
return (tokens / 1_000_000) * usdPerMTok(model) * USD_TO_EUR;
|
||||
}
|
||||
|
||||
interface TaskTokens {
|
||||
tokens: number;
|
||||
estimated: boolean;
|
||||
}
|
||||
|
||||
/** Tokens attributable to a single task, real if recorded else time-estimated. */
|
||||
function tokensForTask(cwd: string, id: string, status: string, createdAt: string, updatedAt: string): TaskTokens {
|
||||
const now = Date.now();
|
||||
const created = Date.parse(createdAt);
|
||||
const updated = Date.parse(updatedAt || createdAt);
|
||||
|
||||
let ms = 0;
|
||||
if (status === 'done') {
|
||||
// Prefer real recorded figures; only these agents' actual numbers are exact.
|
||||
try {
|
||||
const { task } = getTask(cwd, id);
|
||||
if (typeof task.doneTokens === 'number' && task.doneTokens > 0) {
|
||||
return { tokens: task.doneTokens, estimated: false };
|
||||
}
|
||||
if (typeof task.doneDuration === 'number' && task.doneDuration > 0) {
|
||||
ms = task.doneDuration; // actual working duration, not calendar span
|
||||
} else {
|
||||
ms = (isNaN(updated) ? now : updated) - (isNaN(created) ? now : created);
|
||||
}
|
||||
} catch {
|
||||
ms = (isNaN(updated) ? now : updated) - (isNaN(created) ? now : created);
|
||||
}
|
||||
} else if (status === 'in_progress' || status === 'review') {
|
||||
ms = now - (isNaN(updated) ? now : updated); // since it was claimed / sent to review
|
||||
} else {
|
||||
return { tokens: 0, estimated: true }; // open / cancelled — no work yet
|
||||
}
|
||||
|
||||
// Clamp: wall-clock time-in-state wildly overcounts for stale tasks.
|
||||
const minutes = Math.min(Math.max(0, ms) / 60_000, MAX_ACTIVE_MIN_PER_TASK);
|
||||
return { tokens: Math.round(minutes * TOKENS_PER_ACTIVE_MIN), estimated: true };
|
||||
}
|
||||
|
||||
export interface AgentBudget {
|
||||
name: string;
|
||||
role: string;
|
||||
model?: string;
|
||||
kind?: string;
|
||||
tokens: number;
|
||||
realTokens: number;
|
||||
estimatedTokens: number;
|
||||
/** True if any part of this agent's total is an estimate. */
|
||||
estimated: boolean;
|
||||
costEur: number;
|
||||
budgetEur?: number;
|
||||
/** costEur / budgetEur, clamped 0..1 (only when a budget is set). */
|
||||
budgetUsed?: number;
|
||||
taskCount: number;
|
||||
}
|
||||
|
||||
export interface BudgetReport {
|
||||
agents: AgentBudget[];
|
||||
totals: { tokens: number; costEur: number; estimated: boolean };
|
||||
assumptions: { tokensPerActiveMin: number; note: string };
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
/** Compute the per-agent token/cost rollup across all tasks. */
|
||||
export function computeBudget(cwd: string): BudgetReport {
|
||||
const roster = getRoster(cwd);
|
||||
const byName = new Map<string, RosterEntry>();
|
||||
for (const r of roster) byName.set(r.name, r);
|
||||
|
||||
const acc = new Map<string, AgentBudget>();
|
||||
const ensure = (name: string): AgentBudget => {
|
||||
let a = acc.get(name);
|
||||
if (!a) {
|
||||
const r = byName.get(name);
|
||||
a = {
|
||||
name,
|
||||
role: r?.role ?? 'implementer',
|
||||
model: r?.model,
|
||||
kind: r?.kind ?? inferKind(name),
|
||||
tokens: 0,
|
||||
realTokens: 0,
|
||||
estimatedTokens: 0,
|
||||
estimated: false,
|
||||
costEur: 0,
|
||||
budgetEur: r?.budgetEur,
|
||||
taskCount: 0,
|
||||
};
|
||||
acc.set(name, a);
|
||||
}
|
||||
return a;
|
||||
};
|
||||
// Seed rostered agents so they show even at zero.
|
||||
for (const r of roster) ensure(r.name);
|
||||
|
||||
for (const t of listTasks(cwd)) {
|
||||
const owner = t.assignedTo;
|
||||
if (!owner) continue;
|
||||
const { tokens, estimated } = tokensForTask(
|
||||
cwd,
|
||||
t.id,
|
||||
t.status ?? 'open',
|
||||
t.createdAt,
|
||||
t.updatedAt,
|
||||
);
|
||||
if (tokens <= 0 && (t.status === 'open' || t.status === 'cancelled')) continue;
|
||||
const a = ensure(owner);
|
||||
a.tokens += tokens;
|
||||
a.taskCount += 1;
|
||||
if (estimated) {
|
||||
a.estimatedTokens += tokens;
|
||||
a.estimated = true;
|
||||
} else {
|
||||
a.realTokens += tokens;
|
||||
}
|
||||
}
|
||||
|
||||
const agents = Array.from(acc.values());
|
||||
for (const a of agents) {
|
||||
a.costEur = costEur(a.tokens, a.model);
|
||||
if (a.budgetEur && a.budgetEur > 0) {
|
||||
a.budgetUsed = Math.min(1, a.costEur / a.budgetEur);
|
||||
}
|
||||
}
|
||||
agents.sort((x, y) => y.tokens - x.tokens || x.name.localeCompare(y.name));
|
||||
|
||||
const totalTokens = agents.reduce((s, a) => s + a.tokens, 0);
|
||||
const totalCost = agents.reduce((s, a) => s + a.costEur, 0);
|
||||
const anyEstimated = agents.some((a) => a.estimated);
|
||||
|
||||
return {
|
||||
agents,
|
||||
totals: { tokens: totalTokens, costEur: totalCost, estimated: anyEstimated },
|
||||
assumptions: {
|
||||
tokensPerActiveMin: TOKENS_PER_ACTIVE_MIN,
|
||||
note: `External CLIs do not report tokens; figures marked ~ are estimated from active time-on-task (capped at ${MAX_ACTIVE_MIN_PER_TASK} min/task). Cost is a directional estimate at blended model rates, not billing.`,
|
||||
},
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
58
src/core/services/orgService.ts
Normal file
58
src/core/services/orgService.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { z } from 'zod';
|
||||
import { findProjectRoot } from '../paths.js';
|
||||
import { loadConfig, saveConfig } from '../config.js';
|
||||
import { OrgNodeSchema, type OrgNode } from '../schema.js';
|
||||
|
||||
const OrgArray = z.array(OrgNodeSchema).min(1);
|
||||
|
||||
/** Pull the fenced ```agenthub-org … ``` YAML block out of a markdown file. */
|
||||
export function extractOrgBlock(text: string): string | null {
|
||||
const m = text.match(/```agenthub-org\s*\n([\s\S]*?)\n```/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** Parse + validate an org spec (YAML text) into a checked node list. */
|
||||
export function parseOrg(yamlText: string): OrgNode[] {
|
||||
const org = OrgArray.parse(parseYaml(yamlText));
|
||||
const ids = new Set(org.map((n) => n.id));
|
||||
if (ids.size !== org.length) throw new Error('duplicate node id in org block');
|
||||
for (const n of org) {
|
||||
if (n.parentId && !ids.has(n.parentId)) throw new Error(`node "${n.id}" → unknown parentId "${n.parentId}"`);
|
||||
}
|
||||
const roots = org.filter((n) => !n.parentId);
|
||||
if (roots.length !== 1) throw new Error(`org must have exactly one root (found ${roots.length})`);
|
||||
return org;
|
||||
}
|
||||
|
||||
/** Resolve the source markdown file (explicit, else CLAUDE.md / AGENTS.md at root). */
|
||||
export function resolveSourceFile(cwd: string, from?: string): string | undefined {
|
||||
const root = findProjectRoot(cwd) ?? cwd;
|
||||
if (from) return existsSync(from) ? from : undefined;
|
||||
for (const cand of ['CLAUDE.md', 'AGENTS.md']) {
|
||||
const p = join(root, cand);
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the team org chart from an `agenthub-org` block in a markdown file
|
||||
* (CLAUDE.md by default) into `.agenthub/agenthub.config.json`. This makes the
|
||||
* human-readable doc the single source of truth for the team structure — edit
|
||||
* the block, run `agenthub org sync`, and /team reflects it.
|
||||
*/
|
||||
export function syncOrgFromFile(cwd: string, from?: string): { count: number; file: string } {
|
||||
const root = findProjectRoot(cwd) ?? cwd;
|
||||
const file = resolveSourceFile(cwd, from);
|
||||
if (!file) throw new Error('no source file found (looked for CLAUDE.md / AGENTS.md) — pass --from <file>');
|
||||
const block = extractOrgBlock(readFileSync(file, 'utf-8'));
|
||||
if (!block) throw new Error(`no \`\`\`agenthub-org block found in ${file}`);
|
||||
const org = parseOrg(block);
|
||||
const config = loadConfig(root);
|
||||
config.org = org;
|
||||
saveConfig(root, config);
|
||||
return { count: org.length, file };
|
||||
}
|
||||
65
src/core/services/rosterService.ts
Normal file
65
src/core/services/rosterService.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { loadConfig } from '../config.js';
|
||||
import { listTasks } from './taskService.js';
|
||||
|
||||
/** One agent in the team, resolved from the roster (config.agents) with a
|
||||
* fallback to whatever agents actually touched tasks. */
|
||||
export interface RosterEntry {
|
||||
name: string;
|
||||
role: string;
|
||||
model?: string;
|
||||
kind?: string;
|
||||
description?: string;
|
||||
budgetEur?: number;
|
||||
}
|
||||
|
||||
/** Infer a provider ("kind") from an agent name when the roster doesn't say. */
|
||||
export function inferKind(name: string): string | undefined {
|
||||
const n = name.toLowerCase();
|
||||
if (n.includes('claude') || n === 'backyard' || n === 'architect') return 'anthropic';
|
||||
if (n.includes('codex') || n.includes('gpt')) return 'openai';
|
||||
if (n.includes('kimi')) return 'moonshot';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Rough default display model per provider, for agents missing from the roster. */
|
||||
function inferModel(kind?: string): string | undefined {
|
||||
if (kind === 'anthropic') return 'Claude';
|
||||
if (kind === 'openai') return 'GPT-5 Codex';
|
||||
if (kind === 'moonshot') return 'Kimi K2';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The team roster: the named agents from `config.agents` if configured,
|
||||
* otherwise one entry per agent that has touched a task (assignedTo / doneBy),
|
||||
* so the board/budget views work even before a roster is filled in.
|
||||
*/
|
||||
export function getRoster(cwd: string): RosterEntry[] {
|
||||
const config = loadConfig(cwd);
|
||||
|
||||
if (config.agents && Object.keys(config.agents).length > 0) {
|
||||
return Object.entries(config.agents).map(([name, a]) => ({
|
||||
name,
|
||||
role: a.role,
|
||||
model: a.model,
|
||||
kind: a.kind ?? inferKind(name),
|
||||
description: a.description,
|
||||
budgetEur: a.budgetEur,
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback: derive from tasks + the role config's preferred agents.
|
||||
const names = new Set<string>();
|
||||
for (const [, cfg] of Object.entries(config.roles)) names.add(cfg.preferredAgent);
|
||||
const roleByAgent = new Map<string, string>();
|
||||
for (const [role, cfg] of Object.entries(config.roles)) roleByAgent.set(cfg.preferredAgent, role);
|
||||
|
||||
for (const t of listTasks(cwd)) {
|
||||
if (t.assignedTo) names.add(t.assignedTo);
|
||||
}
|
||||
|
||||
return Array.from(names).map((name) => {
|
||||
const kind = inferKind(name);
|
||||
return { name, role: roleByAgent.get(name) ?? 'implementer', kind, model: inferModel(kind) };
|
||||
});
|
||||
}
|
||||
@ -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<void> {
|
||||
});
|
||||
|
||||
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<void> {
|
||||
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<Record<string, unknown> | 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.',
|
||||
|
||||
@ -3,8 +3,7 @@
|
||||
*
|
||||
* Constraints:
|
||||
* - One static HTML page: inline CSS + JS, no framework and no runtime deps.
|
||||
* - Reads only same-origin endpoints (`/tasks`, `/tasks/:id/activity`,
|
||||
* `/events` and `/status`).
|
||||
* - Reads only same-origin endpoints (`/tasks`, `/events` and `/status`).
|
||||
* - Never mutates state.
|
||||
*/
|
||||
|
||||
@ -385,46 +384,6 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
||||
}
|
||||
.empty { color: var(--muted); font-size: 12px; padding: 4px 2px; }
|
||||
|
||||
.timeline {
|
||||
display: none;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
margin-top: 9px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.card.expanded .timeline { display: block; }
|
||||
.tl-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid rgba(48, 54, 61, .5);
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.tl-row:first-child { border-top: 0; }
|
||||
.tl-when { color: var(--muted); font: 11px/1.4 var(--mono); white-space: nowrap; min-width: 52px; }
|
||||
.tl-kind {
|
||||
font: 10px/1.3 var(--mono);
|
||||
border-radius: 999px;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tl-kind-created { background: rgba(48, 54, 61, .6); color: var(--muted); border-color: var(--border); }
|
||||
.tl-kind-handoff { background: rgba(88, 166, 255, .12); color: var(--accent); border-color: rgba(88, 166, 255, .25); }
|
||||
.tl-kind-result { background: rgba(34, 197, 94, .12); color: var(--done); border-color: rgba(34, 197, 94, .25); }
|
||||
.tl-kind-status { background: rgba(210, 153, 34, .12); color: var(--review); border-color: rgba(210, 153, 34, .25); }
|
||||
.tl-actor { color: var(--muted); font: 11px/1.4 var(--mono); min-width: 0; overflow-wrap: anywhere; }
|
||||
.tl-summary { grid-column: 1 / -1; min-width: 0; max-width: 100%; overflow-wrap: anywhere; word-break: break-word; white-space: normal; }
|
||||
.tl-meta { grid-column: 1 / -1; display: flex; gap: 4px; flex-wrap: wrap; min-width: 0; }
|
||||
.badge.tl-tokens { color: #A371F7; border-color: rgba(163, 113, 247, .25); background: rgba(163, 113, 247, .12); }
|
||||
.badge.tl-dur { color: #FFA657; border-color: rgba(255, 166, 87, .25); background: rgba(255, 166, 87, .12); }
|
||||
.tl-loading, .tl-empty { color: var(--muted); font-size: 12px; padding: 2px 0; }
|
||||
|
||||
.panels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@ -475,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;
|
||||
@ -505,6 +678,9 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
||||
<a class="nav-link" href="/activity">Activity</a>
|
||||
<a class="nav-link" href="/decisions">Decisions</a>
|
||||
</nav>
|
||||
<button class="new-task-btn" id="newTaskBtn" type="button" aria-expanded="false">
|
||||
<span class="plus" aria-hidden="true">+</span> New task
|
||||
</button>
|
||||
<span class="sse-status stale" id="sseStatus">
|
||||
<span class="conn-dot" aria-hidden="true"></span>
|
||||
<span id="sseLabel">connecting</span>
|
||||
@ -512,6 +688,29 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="composer" id="composer">
|
||||
<form class="composer-inner" id="composerForm" autocomplete="off">
|
||||
<input class="title" id="ctTitle" type="text" placeholder="What needs doing? (task title)" required maxlength="140" />
|
||||
<select id="ctRole" aria-label="Role">
|
||||
<option value="">any role</option>
|
||||
<option value="implementer" selected>implementer</option>
|
||||
<option value="reviewer">reviewer</option>
|
||||
<option value="tester">tester</option>
|
||||
<option value="architect">architect</option>
|
||||
</select>
|
||||
<select id="ctPriority" aria-label="Priority">
|
||||
<option value="low">low</option>
|
||||
<option value="medium" selected>medium</option>
|
||||
<option value="high">high</option>
|
||||
<option value="critical">critical</option>
|
||||
</select>
|
||||
<button class="create" type="submit">Create</button>
|
||||
<div class="hint">Tip: start the title with an agent name (e.g. <code>codex: …</code>) to address it — moving the card to <b>In Progress</b> then auto-assigns it. Or drag an agent onto a card to assign manually.</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<section class="agents-rail" id="agentsRail" aria-label="Agents — drag one onto a task to assign"></section>
|
||||
|
||||
<section class="dashboard" aria-label="Dashboard metrics">
|
||||
<div class="metrics" id="metrics">
|
||||
<div class="metric-card"><div class="metric-label">Active tasks</div><div class="metric-value" data-metric="active">0</div><span class="metric-icon" style="color:var(--accent)"></span></div>
|
||||
@ -526,8 +725,19 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
||||
<section class="board" id="board" aria-label="Task board">
|
||||
${columnSkeleton()}
|
||||
</section>
|
||||
|
||||
<section class="budget" id="budget" aria-label="Token and cost budget">
|
||||
<div class="budget-head">
|
||||
<h2>Cost & Budget</h2>
|
||||
<span class="est-note" id="budgetNote">estimated from time-on-task</span>
|
||||
<span class="total" id="budgetTotal"></span>
|
||||
</div>
|
||||
<div id="budgetRows"><div class="budget-empty">no agent activity yet</div></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toasts" id="toasts" aria-live="polite"></div>
|
||||
|
||||
<script>
|
||||
var REFRESH_MS = 6000;
|
||||
var TIMER_MS = 1000;
|
||||
@ -555,10 +765,6 @@ ${columnSkeleton()}
|
||||
if (isNaN(t)) return '';
|
||||
return compactDuration(Date.now() - t) + ' ago';
|
||||
}
|
||||
function msToHuman(ms) {
|
||||
if (ms < 1000) return ms + 'ms';
|
||||
return compactDuration(ms);
|
||||
}
|
||||
async function getJSON(path) {
|
||||
var res = await fetch(path, { headers: { accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(path + ' -> ' + res.status);
|
||||
@ -654,55 +860,10 @@ ${columnSkeleton()}
|
||||
setMetric('agents', Object.keys(busy).length + '/' + Object.keys(agents).length);
|
||||
}
|
||||
|
||||
function renderTimelineItems(items) {
|
||||
if (!items || !items.length) return '<div class="tl-empty">no activity yet</div>';
|
||||
return items.map(function(item) {
|
||||
var meta = '';
|
||||
if (item.meta) {
|
||||
if (item.meta.tokens != null) {
|
||||
meta += '<span class="badge tl-tokens">' + esc(item.meta.tokens) + ' tok</span>';
|
||||
}
|
||||
if (item.meta.duration != null) {
|
||||
meta += '<span class="badge tl-dur">' + esc(msToHuman(item.meta.duration)) + '</span>';
|
||||
}
|
||||
}
|
||||
return '<div class="tl-row">' +
|
||||
'<span class="tl-when">' + esc(ago(item.at)) + '</span>' +
|
||||
'<span class="tl-kind tl-kind-' + esc(item.kind) + '">' + esc(item.kind) + '</span>' +
|
||||
'<span class="tl-actor">' + esc(item.actor) + '</span>' +
|
||||
'<span class="tl-summary">' + esc(item.summary) + '</span>' +
|
||||
(meta ? '<span class="tl-meta">' + meta + '</span>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
function loadTimeline(card, id) {
|
||||
var tl = card.querySelector('.timeline');
|
||||
if (!tl) return;
|
||||
tl.innerHTML = '<div class="tl-loading">loading...</div>';
|
||||
getJSON('/tasks/' + encodeURIComponent(id) + '/activity')
|
||||
.then(function(items) { tl.innerHTML = renderTimelineItems(items); })
|
||||
.catch(function() { tl.innerHTML = '<div class="tl-empty">could not load activity</div>'; });
|
||||
}
|
||||
|
||||
document.getElementById('board').addEventListener('click', function(e) {
|
||||
var card = e.target.closest('.card[data-id]');
|
||||
if (!card) return;
|
||||
var id = card.dataset.id;
|
||||
var wasExpanded = card.classList.contains('expanded');
|
||||
card.classList.toggle('expanded');
|
||||
if (!wasExpanded) loadTimeline(card, id);
|
||||
});
|
||||
document.getElementById('board').addEventListener('keydown', function(e) {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
var card = e.target.closest('.card[data-id]');
|
||||
if (!card) return;
|
||||
e.preventDefault();
|
||||
card.click();
|
||||
});
|
||||
|
||||
function taskCard(t) {
|
||||
var status = byStatus(t.status);
|
||||
return '<article class="card" tabindex="0" data-id="' + esc(t.id) + '">' +
|
||||
return '<a class="card" draggable="true" href="/tasks/' + encodeURIComponent(t.id) + '"' +
|
||||
' data-id="' + esc(t.id) + '" data-status="' + esc(status) + '" data-assigned="' + esc(t.assignedTo || '') + '">' +
|
||||
'<div class="card-top">' +
|
||||
'<span class="id">' + esc(t.id) + '</span>' +
|
||||
'<span class="pill status-pill status-' + esc(status) + '">' + esc(statusLabel(status)) + '</span>' +
|
||||
@ -715,8 +876,7 @@ ${columnSkeleton()}
|
||||
esc(timerLabel(status, t.createdAt, t.updatedAt)) +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'<div class="timeline"></div>' +
|
||||
'</article>';
|
||||
'</a>';
|
||||
}
|
||||
function byStatus(status) {
|
||||
return COLUMNS.indexOf(status) >= 0 ? status : 'open';
|
||||
@ -736,23 +896,7 @@ ${columnSkeleton()}
|
||||
var count = document.querySelector('[data-count="' + k + '"]');
|
||||
if (count) count.textContent = String(list.length);
|
||||
if (!cardsEl) return;
|
||||
var expanded = {};
|
||||
cardsEl.querySelectorAll('.card.expanded[data-id]').forEach(function(c) {
|
||||
var timeline = c.querySelector('.timeline');
|
||||
expanded[c.dataset.id] = timeline ? timeline.innerHTML : '';
|
||||
});
|
||||
cardsEl.innerHTML = list.length ? list.map(taskCard).join('') : '<div class="empty">none</div>';
|
||||
Object.keys(expanded).forEach(function(id) {
|
||||
var card = cardsEl.querySelector('.card[data-id="' + id + '"]');
|
||||
if (!card) return;
|
||||
card.classList.add('expanded');
|
||||
var timeline = card.querySelector('.timeline');
|
||||
if (timeline && expanded[id]) {
|
||||
timeline.innerHTML = expanded[id];
|
||||
} else {
|
||||
loadTimeline(card, id);
|
||||
}
|
||||
});
|
||||
});
|
||||
updateTimers();
|
||||
}
|
||||
@ -801,8 +945,10 @@ ${columnSkeleton()}
|
||||
try {
|
||||
var status = await getJSON('/status');
|
||||
var body = String(status && status.body ? status.body : '');
|
||||
var m = body.match(/^#\\s+AgentHub\\s+[\\u2014-]\\s+(.+)$/m) || body.match(/^#\\s+Project\\s+Status\\s*:?\\s*(.+)$/m);
|
||||
applyProjectName(m && m[1] ? m[1].trim() : PROJECT_NAME);
|
||||
var m = body.match(/^#\\s+AgentHub\\s+[\\u2014-]\\s+(.+)$/m) || body.match(/^#\\s+Project\\s+Status[^\\S\\n]*[:\\-][^\\S\\n]*(.+)$/m);
|
||||
var name = m && m[1] ? m[1].trim() : '';
|
||||
// Guard: never let a summary line (long / contains a period) become the name.
|
||||
applyProjectName(name && name.length <= 48 && name.indexOf('.') < 0 ? name : PROJECT_NAME);
|
||||
} catch (_) {
|
||||
applyProjectName(PROJECT_NAME);
|
||||
}
|
||||
@ -811,6 +957,7 @@ ${columnSkeleton()}
|
||||
try {
|
||||
var tasks = await getJSON('/tasks');
|
||||
renderBoard(tasks);
|
||||
renderAgentsRail();
|
||||
setConn('ok', eventSourceReady ? 'connected' : 'polling');
|
||||
} catch (_) {
|
||||
setConn('down', 'offline');
|
||||
@ -844,6 +991,7 @@ ${columnSkeleton()}
|
||||
stopFallbackPoll();
|
||||
setConn('ok', 'connected');
|
||||
refresh();
|
||||
refreshBudget();
|
||||
};
|
||||
source.onerror = function() {
|
||||
eventSourceReady = false;
|
||||
@ -852,10 +1000,287 @@ ${columnSkeleton()}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Roster + agents rail ────────────────────────────────────────────────
|
||||
var AGENTS = [];
|
||||
var BUDGET = null;
|
||||
|
||||
function agentBusyMap() {
|
||||
var m = {};
|
||||
(lastTasks || []).forEach(function(t) {
|
||||
if (t.status === 'in_progress' && t.assignedTo) m[t.assignedTo] = true;
|
||||
});
|
||||
return m;
|
||||
}
|
||||
function renderAgentsRail() {
|
||||
var rail = document.getElementById('agentsRail');
|
||||
if (!rail) return;
|
||||
if (!AGENTS.length) { rail.innerHTML = ''; return; }
|
||||
var busy = agentBusyMap();
|
||||
var html = '<span class="rail-label">drag onto a task →</span>';
|
||||
html += AGENTS.map(function(a) {
|
||||
var spec = agentSpec(a.name);
|
||||
var b = busy[a.name];
|
||||
return '<div class="agent-chip' + (b ? ' busy' : '') + '" draggable="true" data-agent="' + esc(a.name) + '"' +
|
||||
' style="--agent-color:' + esc(spec.color) + '" title="Drag ' + esc(a.name) + ' onto a task card to assign it (notified in realtime)">' +
|
||||
'<span class="ac-core">' + esc(spec.initials) + '</span>' +
|
||||
'<span class="ac-meta"><span class="ac-name">' + esc(a.name) + '</span>' +
|
||||
'<span class="ac-sub">' + esc(a.model || a.role || '') + '</span></span>' +
|
||||
'<span class="ac-busy" aria-hidden="true"></span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
rail.innerHTML = html;
|
||||
}
|
||||
async function loadAgents() {
|
||||
try {
|
||||
AGENTS = await getJSON('/agents');
|
||||
} catch (_) { AGENTS = []; }
|
||||
renderAgentsRail();
|
||||
}
|
||||
|
||||
// ── Budget panel ────────────────────────────────────────────────────────
|
||||
function fmtTokens(n) {
|
||||
n = Number(n) || 0;
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
||||
return String(Math.round(n));
|
||||
}
|
||||
function fmtEur(n) { return '\\u20ac' + (Number(n) || 0).toFixed(2); }
|
||||
function maxTok(list) { return list.reduce(function(m, a) { return Math.max(m, a.tokens || 0); }, 0); }
|
||||
function renderBudget(rep) {
|
||||
BUDGET = rep;
|
||||
var rows = document.getElementById('budgetRows');
|
||||
var total = document.getElementById('budgetTotal');
|
||||
if (!rows || !total) return;
|
||||
var active = (rep && rep.agents ? rep.agents : []).filter(function(a) { return a.tokens > 0; });
|
||||
if (!active.length) {
|
||||
rows.innerHTML = '<div class="budget-empty">no tokens spent yet — assign a task to an agent to see live estimates</div>';
|
||||
total.textContent = '';
|
||||
return;
|
||||
}
|
||||
var mx = maxTok(active) || 1;
|
||||
rows.innerHTML = active.map(function(a) {
|
||||
var spec = agentSpec(a.name);
|
||||
var est = a.estimated ? '<span class="est">~</span>' : '';
|
||||
var pct, over = false;
|
||||
if (a.budgetEur) { pct = Math.round((a.budgetUsed || 0) * 100); over = pct >= 100; }
|
||||
else { pct = Math.round((a.tokens / mx) * 100); }
|
||||
var money = a.budgetEur
|
||||
? fmtEur(a.costEur) + ' / ' + fmtEur(a.budgetEur)
|
||||
: '\\u2248 ' + fmtEur(a.costEur);
|
||||
return '<div class="budget-row">' +
|
||||
'<div class="budget-agent"><span class="ba-core" style="background:' + esc(spec.color) + '">' + esc(spec.initials) + '</span>' +
|
||||
'<span style="min-width:0"><span class="ba-name">' + esc(a.name) + '</span><br>' +
|
||||
'<span class="ba-model">' + esc(a.model || a.role || '') + '</span></span></div>' +
|
||||
'<div class="budget-bar' + (over ? ' over' : '') + '"><span style="width:' + Math.min(100, pct) + '%"></span></div>' +
|
||||
'<div class="budget-tokens"><span class="tok">' + est + fmtTokens(a.tokens) + ' tok</span>' +
|
||||
'<span class="eur">' + money + '</span></div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
total.innerHTML = (rep.totals.estimated ? '~' : '') + fmtTokens(rep.totals.tokens) +
|
||||
' tok <small>\\u00b7 \\u2248 ' + fmtEur(rep.totals.costEur) + '</small>';
|
||||
}
|
||||
async function refreshBudget() {
|
||||
try { renderBudget(await getJSON('/budget')); } catch (_) {}
|
||||
}
|
||||
|
||||
// ── Toasts ──────────────────────────────────────────────────────────────
|
||||
function toast(msg, opts) {
|
||||
opts = opts || {};
|
||||
var wrap = document.getElementById('toasts');
|
||||
if (!wrap) return;
|
||||
var el = document.createElement('div');
|
||||
el.className = 'toast' + (opts.error ? ' err' : '');
|
||||
el.innerHTML = '<span class="dot" aria-hidden="true"></span><span>' + esc(msg) + '</span>';
|
||||
wrap.appendChild(el);
|
||||
setTimeout(function() {
|
||||
el.classList.add('out');
|
||||
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 240);
|
||||
}, opts.ms || 3200);
|
||||
}
|
||||
|
||||
// ── Mutations (create / assign / move) ──────────────────────────────────
|
||||
async function postTask(body) {
|
||||
var res = await fetch('/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error('create failed (' + res.status + ')');
|
||||
return res.json();
|
||||
}
|
||||
async function patchTask(id, body) {
|
||||
var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) {
|
||||
var msg = 'update failed (' + res.status + ')';
|
||||
try { var j = await res.json(); if (j && j.error) msg = j.error; } catch (_) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
function flashCard(id) {
|
||||
var el = document.querySelector('.card[data-id="' + id + '"]');
|
||||
if (!el) return;
|
||||
el.classList.add('flash');
|
||||
setTimeout(function() { el.classList.remove('flash'); }, 900);
|
||||
}
|
||||
function pingAgent(agent) {
|
||||
var chip = document.querySelector('.agent-chip[data-agent="' + agent + '"]');
|
||||
if (!chip) return;
|
||||
chip.classList.remove('ping'); void chip.offsetWidth; chip.classList.add('ping');
|
||||
}
|
||||
|
||||
// Derive the addressed agent from a task title's "name:" prefix (delegation
|
||||
// convention), matched case-insensitively against the known roster.
|
||||
function agentFromTitle(id) {
|
||||
var t = (lastTasks || []).find(function(x) { return x.id === id; });
|
||||
if (!t || !t.title) return '';
|
||||
var m = String(t.title).match(/^([A-Za-z][A-Za-z0-9_-]*)\\s*:/);
|
||||
if (!m) return '';
|
||||
var name = m[1].toLowerCase();
|
||||
var hit = (AGENTS || []).find(function(a) { return String(a.name).toLowerCase() === name; });
|
||||
return hit ? hit.name : '';
|
||||
}
|
||||
|
||||
// Manual (re)assign: an agent chip was dragged onto a task card.
|
||||
async function assignAgentToCard(id, agent) {
|
||||
try {
|
||||
await patchTask(id, { assignedTo: agent });
|
||||
pingAgent(agent);
|
||||
toast(id + ' \\u2192 @' + agent + ' \\u00b7 notified in realtime');
|
||||
await refresh(); await refreshBudget(); flashCard(id);
|
||||
} catch (e) { toast(e.message || 'assign failed', { error: true }); }
|
||||
}
|
||||
async function onDropColumn(id, status, assigned) {
|
||||
var card = document.querySelector('.card[data-id="' + id + '"]');
|
||||
var from = card ? card.dataset.status : '';
|
||||
if (from === status) return;
|
||||
try {
|
||||
if (status === 'in_progress') {
|
||||
// Auto-assign: use the current assignee, else the agent named in the title.
|
||||
var agent = assigned || agentFromTitle(id);
|
||||
if (!agent) {
|
||||
toast('No agent — start the title with "name:" or drag an agent onto the card', { error: true, ms: 4200 });
|
||||
return;
|
||||
}
|
||||
await patchTask(id, { status: 'in_progress', assignedTo: agent });
|
||||
pingAgent(agent);
|
||||
toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (assigned ? '' : ' (from title)'));
|
||||
} else if (status === 'review') {
|
||||
await patchTask(id, { status: 'review' });
|
||||
toast(id + ' \\u2192 review');
|
||||
} else if (status === 'open') {
|
||||
await patchTask(id, { status: 'open' });
|
||||
toast(id + ' \\u2192 reopened');
|
||||
} else { return; }
|
||||
await refresh(); await refreshBudget(); flashCard(id);
|
||||
} catch (e) { toast(e.message || 'move failed', { error: true }); }
|
||||
}
|
||||
|
||||
// ── Drag & drop wiring (event delegation) ───────────────────────────────
|
||||
// Two drag kinds:
|
||||
// • 'card' → drop on a COLUMN to change status (in_progress auto-assigns).
|
||||
// • 'agent' → drop on a CARD to (re)assign that agent to the task.
|
||||
var dragKind = null, dragId = null, dragAgent = null, lastTarget = null;
|
||||
function dropTargetOf(e) {
|
||||
var n = e.target;
|
||||
if (!n || !n.closest) return null;
|
||||
if (dragKind === 'agent') return n.closest('.card');
|
||||
if (dragKind === 'card') return n.closest('.column');
|
||||
return null;
|
||||
}
|
||||
document.addEventListener('dragstart', function(e) {
|
||||
var chip = e.target.closest && e.target.closest('.agent-chip');
|
||||
if (chip) {
|
||||
dragKind = 'agent'; dragAgent = chip.dataset.agent;
|
||||
chip.classList.add('dragging');
|
||||
if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'copy'; try { e.dataTransfer.setData('text/plain', dragAgent); } catch (_) {} }
|
||||
return;
|
||||
}
|
||||
var card = e.target.closest && e.target.closest('.card');
|
||||
if (card) {
|
||||
dragKind = 'card'; dragId = card.dataset.id;
|
||||
card.classList.add('dragging');
|
||||
if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', dragId); } catch (_) {} }
|
||||
}
|
||||
});
|
||||
document.addEventListener('dragend', function(e) {
|
||||
var el = e.target.closest && (e.target.closest('.agent-chip') || e.target.closest('.card'));
|
||||
if (el) {
|
||||
el.classList.remove('dragging');
|
||||
// Guard the trailing click so a drag doesn't also open the task detail.
|
||||
el.classList.add('just-dragged');
|
||||
setTimeout(function() { el.classList.remove('just-dragged'); }, 350);
|
||||
}
|
||||
if (lastTarget) { lastTarget.classList.remove('drop-active'); lastTarget = null; }
|
||||
dragKind = null; dragId = null; dragAgent = null;
|
||||
});
|
||||
document.addEventListener('dragover', function(e) {
|
||||
if (!dragKind) return;
|
||||
var tgt = dropTargetOf(e);
|
||||
if (!tgt) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = dragKind === 'agent' ? 'copy' : 'move';
|
||||
if (lastTarget && lastTarget !== tgt) lastTarget.classList.remove('drop-active');
|
||||
tgt.classList.add('drop-active');
|
||||
lastTarget = tgt;
|
||||
});
|
||||
document.addEventListener('dragleave', function(e) {
|
||||
var tgt = dropTargetOf(e);
|
||||
if (tgt && e.relatedTarget && !tgt.contains(e.relatedTarget)) {
|
||||
tgt.classList.remove('drop-active');
|
||||
if (lastTarget === tgt) lastTarget = null;
|
||||
}
|
||||
});
|
||||
document.addEventListener('drop', function(e) {
|
||||
if (!dragKind) return;
|
||||
var tgt = dropTargetOf(e);
|
||||
if (!tgt) return;
|
||||
e.preventDefault();
|
||||
tgt.classList.remove('drop-active');
|
||||
if (dragKind === 'agent') {
|
||||
assignAgentToCard(tgt.dataset.id, dragAgent);
|
||||
} else {
|
||||
var card = document.querySelector('.card[data-id="' + dragId + '"]');
|
||||
var assigned = card ? (card.dataset.assigned || '') : '';
|
||||
onDropColumn(dragId, tgt.dataset.column, assigned);
|
||||
}
|
||||
});
|
||||
// A drag that ends in a real drop still fires a click on the <a> card — swallow it.
|
||||
document.addEventListener('click', function(e) {
|
||||
var card = e.target.closest && e.target.closest('.card');
|
||||
if (card && card.classList.contains('just-dragged')) { e.preventDefault(); card.classList.remove('just-dragged'); }
|
||||
}, true);
|
||||
|
||||
// ── New-task composer ───────────────────────────────────────────────────
|
||||
(function() {
|
||||
var btn = document.getElementById('newTaskBtn');
|
||||
var panel = document.getElementById('composer');
|
||||
var form = document.getElementById('composerForm');
|
||||
if (!btn || !panel || !form) return;
|
||||
btn.addEventListener('click', function() {
|
||||
var open = panel.classList.toggle('open');
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
if (open) { var t = document.getElementById('ctTitle'); if (t) t.focus(); }
|
||||
});
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
var title = (document.getElementById('ctTitle').value || '').trim();
|
||||
if (!title) return;
|
||||
var role = document.getElementById('ctRole').value || undefined;
|
||||
var priority = document.getElementById('ctPriority').value || undefined;
|
||||
try {
|
||||
var task = await postTask({ title: title, role: role, priority: priority });
|
||||
toast(task.id + ' created');
|
||||
document.getElementById('ctTitle').value = '';
|
||||
panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false');
|
||||
await refresh(); await refreshBudget(); flashCard(task.id);
|
||||
} catch (err) { toast(err.message || 'create failed', { error: true }); }
|
||||
});
|
||||
})();
|
||||
|
||||
loadStatusMeta();
|
||||
loadAgents();
|
||||
refresh();
|
||||
refreshBudget();
|
||||
connectEvents();
|
||||
setInterval(updateTimers, TIMER_MS);
|
||||
setInterval(refreshBudget, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -38,7 +38,7 @@ export function renderDecisionsHtml(cwd: string): string {
|
||||
const rows = decisions.length
|
||||
? decisions
|
||||
.map(
|
||||
(d) => `<article class="decision">
|
||||
(d) => `<article class="decision" id="${escapeHtml(d.id)}">
|
||||
<div class="decision-top">
|
||||
<span class="id">${escapeHtml(d.id)}</span>
|
||||
<span class="status">${escapeHtml(d.status)}</span>
|
||||
|
||||
@ -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<typeof startDiscoveryBroadcaster> | 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;
|
||||
|
||||
52
src/server/mdns.ts
Normal file
52
src/server/mdns.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { Bonjour, type Service } from 'bonjour-service';
|
||||
|
||||
/**
|
||||
* Advertise the hub over mDNS/Bonjour as `<name>.local`, so anyone on the LAN
|
||||
* can open it in a browser without knowing the IP — e.g.
|
||||
* `http://agenthub.local:3377` (or just `http://agenthub.local` when the hub
|
||||
* runs on port 80).
|
||||
*
|
||||
* Best-effort, exactly like the UDP discovery broadcaster: if mDNS is
|
||||
* unavailable or multicast is blocked on the network, we quietly carry on —
|
||||
* the IP + UDP discovery still work.
|
||||
*/
|
||||
export interface MdnsHandle {
|
||||
hostname: string;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export function startMdnsAdvertise(opts: { port: number; name?: string }): MdnsHandle | undefined {
|
||||
const base = (opts.name ?? 'agenthub').toLowerCase().replace(/[^a-z0-9-]/g, '') || 'agenthub';
|
||||
const hostname = `${base}.local`;
|
||||
try {
|
||||
const bonjour = new Bonjour();
|
||||
const service: Service = bonjour.publish({
|
||||
name: 'AgentHub',
|
||||
type: 'http',
|
||||
port: opts.port,
|
||||
host: hostname,
|
||||
txt: { path: '/board' },
|
||||
});
|
||||
// Swallow responder errors — advertisement is optional infrastructure.
|
||||
service.on('error', () => {
|
||||
/* best-effort */
|
||||
});
|
||||
return {
|
||||
hostname,
|
||||
stop: () => {
|
||||
try {
|
||||
service.stop?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
bonjour.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,9 @@ 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 { loadConfig } from '../core/config.js';
|
||||
import { computeBudget } from '../core/services/budgetService.js';
|
||||
import { getRoster } from '../core/services/rosterService.js';
|
||||
import { loadConfig, saveConfig } from '../core/config.js';
|
||||
import { renderActivityHtml } from './activity.js';
|
||||
import { renderBoardHtml } from './board.js';
|
||||
import { renderTeamHtml } from './team.js';
|
||||
@ -18,6 +20,8 @@ import { eventBus, emitChange } from './events.js';
|
||||
import type { AgentHubEvent } from './events.js';
|
||||
import type { Task, Handoff, Decision, Memory, Message } from '../core/schema.js';
|
||||
|
||||
const SSE_KEEPALIVE_MS = 10_000;
|
||||
|
||||
function notFound(reply: FastifyReply, resource: string) {
|
||||
return reply.status(404).send({ error: `${resource} not found` });
|
||||
}
|
||||
@ -32,6 +36,16 @@ function wantsHtml(request: { headers: { accept?: string } }): boolean {
|
||||
}
|
||||
|
||||
export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> {
|
||||
// Never let a browser cache a hub HTML page — otherwise a stale board/team
|
||||
// page keeps showing old markup and doesn't reflect live task/agent state.
|
||||
app.addHook('onSend', async (_request, reply, payload) => {
|
||||
const ct = reply.getHeader('content-type');
|
||||
if (typeof ct === 'string' && ct.includes('text/html')) {
|
||||
reply.header('Cache-Control', 'no-store, must-revalidate');
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
|
||||
// Static, self-contained Trello-like board. Polls /tasks, /handoffs and
|
||||
// /decisions on the same origin; no build step, no deps. Cached once — the
|
||||
// markup is constant, only the data it fetches changes.
|
||||
@ -58,8 +72,9 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
// GET /events?role=<role>
|
||||
//
|
||||
// Keeps the connection open and streams JSON-encoded AgentHubEvent objects
|
||||
// as SSE data lines. Sends a keepalive comment (": \n\n") every 25 s so
|
||||
// proxies and clients detect the connection is still alive.
|
||||
// as SSE data lines. Sends an immediate keepalive comment and then another
|
||||
// one every 10 s so proxies, fetch(), and MCP clients don't treat an idle
|
||||
// work-loop request as a dead connection.
|
||||
//
|
||||
// Optional ?role= filter: tasks whose role doesn't match are dropped
|
||||
// server-side. All handoff / decision / memory events are always forwarded.
|
||||
@ -81,6 +96,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
raw.flushHeaders();
|
||||
raw.write(': connected\n\n');
|
||||
|
||||
const listener = (event: AgentHubEvent) => {
|
||||
// Server-side role filter: skip tasks that belong to a different role.
|
||||
@ -94,8 +110,8 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
eventBus.on('change', listener);
|
||||
|
||||
const keepAliveTimer = setInterval(() => {
|
||||
raw.write(':\n\n');
|
||||
}, 25_000);
|
||||
raw.write(': keepalive\n\n');
|
||||
}, SSE_KEEPALIVE_MS);
|
||||
|
||||
// Clean up when the client disconnects (or the server closes).
|
||||
request.raw.on('close', () => {
|
||||
@ -124,6 +140,25 @@ 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));
|
||||
|
||||
// Rename an org-chart node's display label (inline edit on /team).
|
||||
app.patch('/org/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { label } = (request.body ?? {}) as { label?: string };
|
||||
if (!label || typeof label !== 'string' || !label.trim()) return badRequest(reply, 'label is required');
|
||||
const config = loadConfig(cwd);
|
||||
const node = config.org?.find((n) => n.id === id);
|
||||
if (!node) return notFound(reply, 'Org node');
|
||||
node.label = label.trim().slice(0, 60);
|
||||
saveConfig(cwd, config);
|
||||
return { ok: true, id, label: node.label };
|
||||
});
|
||||
|
||||
// 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 };
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { loadConfig } from '../core/config.js';
|
||||
import { getTask } from '../core/services/taskService.js';
|
||||
import { getTaskActivity } from '../core/services/activityService.js';
|
||||
import { getDecision } from '../core/services/decisionService.js';
|
||||
import { getHandoff, listHandoffs } from '../core/services/handoffService.js';
|
||||
import { agentAvatar, designTokensCss, escapeHtml, statusPill } from './ui-shared.js';
|
||||
import type { Handoff } from '../core/schema.js';
|
||||
import type { ActivityItem, Decision, Handoff } from '../core/schema.js';
|
||||
|
||||
function ago(iso: string): string {
|
||||
const t = Date.parse(iso);
|
||||
@ -17,6 +18,41 @@ function ago(iso: string): string {
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function compactDuration(ms: number): string {
|
||||
const s = Math.max(0, Math.floor(ms / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 48) return `${h}h`;
|
||||
return `${Math.floor(h / 24)}d`;
|
||||
}
|
||||
|
||||
function elapsed(startIso: string, endIso?: string): string {
|
||||
const start = Date.parse(startIso);
|
||||
const end = Date.parse(endIso ?? new Date().toISOString());
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) return '';
|
||||
return compactDuration(end - start);
|
||||
}
|
||||
|
||||
function msToHuman(value: unknown): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '';
|
||||
if (value < 1000) return `${value}ms`;
|
||||
return compactDuration(value);
|
||||
}
|
||||
|
||||
function metaBadges(meta: ActivityItem['meta']): string {
|
||||
if (!meta) return '';
|
||||
const badges: string[] = [];
|
||||
if (typeof meta.tokens === 'number') badges.push(`<span class="badge badge-tokens">${escapeHtml(String(meta.tokens))} tok</span>`);
|
||||
if (typeof meta.duration === 'number') badges.push(`<span class="badge badge-duration">${escapeHtml(msToHuman(meta.duration))}</span>`);
|
||||
if (typeof meta.handoffId === 'string') badges.push(`<span class="badge">${escapeHtml(meta.handoffId)}</span>`);
|
||||
if (typeof meta.memoryId === 'string') badges.push(`<span class="badge">${escapeHtml(meta.memoryId)}</span>`);
|
||||
if (typeof meta.status === 'string') badges.push(`<span class="badge">${escapeHtml(meta.status)}</span>`);
|
||||
if (typeof meta.by === 'string') badges.push(`<span class="badge">by ${escapeHtml(meta.by)}</span>`);
|
||||
return badges.length ? `<div class="badges">${badges.join('')}</div>` : '';
|
||||
}
|
||||
|
||||
export function renderTaskDetailHtml(cwd: string, id: string): string {
|
||||
const config = loadConfig(cwd);
|
||||
const { task, body } = getTask(cwd, id);
|
||||
@ -44,6 +80,25 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
||||
}
|
||||
});
|
||||
|
||||
const decisionIds = Array.from(new Set(handoffs.flatMap((h) => h.decisions ?? [])));
|
||||
const decisions: Decision[] = decisionIds
|
||||
.map((decisionId) => {
|
||||
try {
|
||||
return getDecision(cwd, decisionId).decision;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
.filter((d): d is Decision => Boolean(d));
|
||||
|
||||
const taskStats = [
|
||||
`<span class="stat"><strong>created</strong>${escapeHtml(ago(task.createdAt))}</span>`,
|
||||
`<span class="stat"><strong>updated</strong>${escapeHtml(ago(task.updatedAt))}</span>`,
|
||||
`<span class="stat"><strong>duration</strong>${escapeHtml(elapsed(task.createdAt, task.status === 'done' || task.status === 'cancelled' ? task.updatedAt : undefined))}</span>`,
|
||||
task.doneTokens != null ? `<span class="stat"><strong>tokens</strong>${escapeHtml(String(task.doneTokens))}</span>` : '',
|
||||
task.doneDuration != null ? `<span class="stat"><strong>done time</strong>${escapeHtml(msToHuman(task.doneDuration))}</span>` : '',
|
||||
].filter(Boolean).join('');
|
||||
|
||||
const handoffRows = handoffs.length
|
||||
? handoffs
|
||||
.map(
|
||||
@ -51,20 +106,36 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
||||
<div class="item-top"><span class="id">${escapeHtml(h.id)}</span><span class="when">${escapeHtml(ago(h.createdAt))}</span></div>
|
||||
<div class="route">${escapeHtml(h.fromRole)} ${h.fromAgent ? agentAvatar(h.fromAgent, { size: 22 }) : ''}<span>to</span>${escapeHtml(h.toRole)} ${h.toAgent ? agentAvatar(h.toAgent, { size: 22 }) : ''}</div>
|
||||
<p>${escapeHtml(h.summary)}</p>
|
||||
${h.decisions?.length ? `<div class="badges">${h.decisions.map((decisionId) => `<a class="badge" href="/decisions#${escapeHtml(decisionId)}">${escapeHtml(decisionId)}</a>`).join('')}</div>` : ''}
|
||||
</article>`,
|
||||
)
|
||||
.join('')
|
||||
: '<div class="empty">No handoffs for this task.</div>';
|
||||
|
||||
const decisionRows = decisions.length
|
||||
? decisions
|
||||
.map(
|
||||
(d) => `<article class="item" id="${escapeHtml(d.id)}">
|
||||
<div class="item-top"><span class="id">${escapeHtml(d.id)}</span><span class="badge decision-status">${escapeHtml(d.status)}</span><span class="when">${escapeHtml(ago(d.updatedAt || d.createdAt))}</span></div>
|
||||
<h3>${escapeHtml(d.title)}</h3>
|
||||
<p><strong>Decision</strong> ${escapeHtml(d.decision)}</p>
|
||||
${d.context ? `<p><strong>Reasoning</strong> ${escapeHtml(d.context)}</p>` : ''}
|
||||
${d.consequences?.length ? `<div class="meta-block"><strong>Consequences</strong>${d.consequences.map((c) => `<span>${escapeHtml(c)}</span>`).join('')}</div>` : ''}
|
||||
${d.alternatives?.length ? `<div class="meta-block"><strong>Alternatives</strong>${d.alternatives.map((a) => `<span>${escapeHtml(a)}</span>`).join('')}</div>` : ''}
|
||||
</article>`,
|
||||
)
|
||||
.join('')
|
||||
: '<div class="empty">No linked decisions for this task.</div>';
|
||||
|
||||
const activityRows = activity.length
|
||||
? activity
|
||||
.map(
|
||||
(a) => `<div class="activity-row">
|
||||
(a) => `<article class="activity-row">
|
||||
<span class="when">${escapeHtml(ago(a.at))}</span>
|
||||
<span class="kind">${escapeHtml(a.kind)}</span>
|
||||
<span class="summary">${escapeHtml(a.summary)}</span>
|
||||
<div class="summary">${escapeHtml(a.summary)}${metaBadges(a.meta)}</div>
|
||||
<span class="actor">${escapeHtml(a.actor)}</span>
|
||||
</div>`,
|
||||
</article>`,
|
||||
)
|
||||
.join('')
|
||||
: '<div class="empty">No activity yet.</div>';
|
||||
@ -91,11 +162,22 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
||||
.topline,.item-top { display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px; }
|
||||
h1 { font-size:20px;line-height:1.25;margin:0 0 10px;overflow-wrap:anywhere; }
|
||||
h2 { font-size:14px;margin:0 0 10px; }
|
||||
h3 { font-size:13px;margin:0 0 8px;overflow-wrap:anywhere; }
|
||||
pre { margin:10px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);font:12px/1.5 var(--font-mono); }
|
||||
.stats { display:flex;gap:6px;flex-wrap:wrap;margin:8px 0 0; }
|
||||
.stat,.badge { display:inline-flex;align-items:center;gap:5px;border:1px solid rgba(148,163,184,.25);background:rgba(148,163,184,.08);border-radius:999px;padding:2px 8px;color:var(--muted);font:11px/1.4 var(--font-mono);text-decoration:none; }
|
||||
.stat strong { color:var(--text);font-weight:650; }
|
||||
.badge-tokens { color:#A371F7;border-color:rgba(163,113,247,.28);background:rgba(163,113,247,.12); }
|
||||
.badge-duration { color:#FFA657;border-color:rgba(255,166,87,.28);background:rgba(255,166,87,.12); }
|
||||
.decision-status { color:var(--status-done);border-color:rgba(34,197,94,.32);background:rgba(34,197,94,.10); }
|
||||
.route { display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:var(--muted);font-size:12px;margin-bottom:6px; }
|
||||
.item { border-top:1px solid var(--border);padding:10px 0; }
|
||||
.item:first-of-type { border-top:0;padding-top:0; }
|
||||
.item p { margin:0;overflow-wrap:anywhere; }
|
||||
.item p { margin:0 0 8px;overflow-wrap:anywhere; }
|
||||
.item p:last-child { margin-bottom:0; }
|
||||
.badges { display:flex;gap:5px;flex-wrap:wrap;margin-top:6px; }
|
||||
.meta-block { display:grid;gap:4px;margin-top:8px;color:var(--muted);font-size:12px; }
|
||||
.meta-block strong { color:var(--text); }
|
||||
.activity-row { display:grid;grid-template-columns:80px 82px minmax(0,1fr) auto;gap:8px;align-items:start;border-top:1px solid var(--border);padding:8px 0; }
|
||||
.activity-row:first-child { border-top:0;padding-top:0; }
|
||||
.kind { color:var(--accent);font:11px/1.4 var(--font-mono); }
|
||||
@ -120,12 +202,17 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
||||
<section class="hero">
|
||||
<div class="topline"><span class="id">${escapeHtml(task.id)}</span>${statusPill(task.status)}${task.assignedTo ? agentAvatar(task.assignedTo, { size: 24 }) : ''}</div>
|
||||
<h1>${escapeHtml(task.title)}</h1>
|
||||
<div class="stats">${taskStats}</div>
|
||||
${body.trim() ? `<pre>${escapeHtml(body.trim())}</pre>` : ''}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Handoffs</h2>
|
||||
${handoffRows}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Decisions & Reasoning</h2>
|
||||
${decisionRows}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Activity</h2>
|
||||
${activityRows}
|
||||
|
||||
@ -1,179 +1,148 @@
|
||||
/**
|
||||
* Team hierarchy page, served at `GET /team`.
|
||||
* Team org-chart page, served at `GET /team`.
|
||||
*
|
||||
* 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.
|
||||
* Renders `config.org` — an arbitrary-depth hierarchy (CEO → architect →
|
||||
* product owners → implementers, strategist → compliance, …). Falls back to a
|
||||
* synthesised 2-level tree (architect → workers) when no org is configured.
|
||||
*
|
||||
* The green "traveling pulse" is EVENT-DRIVEN and flows along the real tree
|
||||
* edges: a delegation (task → an agent) sends a pulse DOWN the path from the
|
||||
* architect to that agent's node, lighting each node it passes and turning the
|
||||
* target "active"; a review submission sends an amber pulse back UP. Node names
|
||||
* are editable inline (persisted via PATCH /org/:id) and each node has an info
|
||||
* button describing its job. Live state comes over SSE.
|
||||
*/
|
||||
|
||||
import { loadConfig } from '../core/config.js';
|
||||
import { listTasks } from '../core/services/taskService.js';
|
||||
import {
|
||||
agentAvatar,
|
||||
designTokensCss,
|
||||
escapeHtml,
|
||||
liveTimerJs,
|
||||
pageHeader,
|
||||
providerLogo,
|
||||
providerMeta,
|
||||
statusPill,
|
||||
} from './ui-shared.js';
|
||||
import type { Task } from '../core/schema.js';
|
||||
import { designTokensCss, escapeHtml, pageHeader, providerLogo, providerMeta } from './ui-shared.js';
|
||||
import type { Config, OrgNode, Task } from '../core/schema.js';
|
||||
|
||||
/** The task fields the index actually carries + that this page needs. */
|
||||
type TaskRow = Pick<Task, 'id' | 'title' | 'status' | 'assignedTo' | 'createdAt' | 'updatedAt'>;
|
||||
type TaskRow = Pick<Task, 'id' | 'status' | 'assignedTo' | 'updatedAt'>;
|
||||
interface AgentState { state: 'active' | 'reviewing'; task: TaskRow; }
|
||||
|
||||
interface RosterAgent {
|
||||
name: string;
|
||||
role: string;
|
||||
model?: string;
|
||||
kind?: string;
|
||||
description?: string;
|
||||
busyTask?: TaskRow;
|
||||
/** Per-agent live state for the initial server render (in_progress → active). */
|
||||
function computeStates(cwd: string): Map<string, AgentState> {
|
||||
const tasks = listTasks(cwd) as TaskRow[];
|
||||
const active = new Map<string, TaskRow>();
|
||||
const review = new Map<string, TaskRow>();
|
||||
for (const t of tasks) {
|
||||
if (!t.assignedTo) continue;
|
||||
if (t.status === 'in_progress') { const e = active.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) active.set(t.assignedTo, t); }
|
||||
else if (t.status === 'review') { const e = review.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) review.set(t.assignedTo, t); }
|
||||
}
|
||||
const out = new Map<string, AgentState>();
|
||||
for (const [name, task] of active) out.set(name, { state: 'active', task });
|
||||
for (const [name, task] of review) if (!out.has(name)) out.set(name, { state: 'reviewing', task });
|
||||
return out;
|
||||
}
|
||||
|
||||
const ROLE_ORDER: Record<string, number> = { architect: 0, implementer: 1, reviewer: 2, tester: 3 };
|
||||
const PROVIDER_ORDER: Record<string, number> = { anthropic: 0, openai: 1, moonshot: 2 };
|
||||
|
||||
/** Build the roster from config.agents, or fall back to the role config. */
|
||||
function gatherRoster(cwd: string): RosterAgent[] {
|
||||
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,
|
||||
description: a.description,
|
||||
}));
|
||||
/** Build the org tree from config, synthesising a flat one if none is set. */
|
||||
function resolveOrg(config: Config): { nodes: OrgNode[]; rootId: string } {
|
||||
if (config.org && config.org.length) {
|
||||
const root = config.org.find((n) => !n.parentId) ?? config.org[0];
|
||||
return { nodes: config.org, rootId: root.id };
|
||||
}
|
||||
// Fallback: no roster configured — derive one agent per role from config.roles.
|
||||
return Object.entries(config.roles).map(([role, cfg]) => ({ name: cfg.preferredAgent, role }));
|
||||
// Fallback: architect at the root, everyone else below it.
|
||||
const roster: Array<{ name: string; role: string; kind?: string }> = config.agents
|
||||
? Object.entries(config.agents).map(([name, a]) => ({ name, role: a.role, kind: a.kind }))
|
||||
: Object.entries(config.roles).map(([role, cfg]) => ({ name: cfg.preferredAgent, role }));
|
||||
const arch = roster.find((r) => r.role === 'architect') ?? roster[0];
|
||||
const rootId = 'root:' + (arch ? arch.name : 'architect');
|
||||
const nodes: OrgNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
if (arch) { nodes.push({ id: rootId, label: arch.name, agent: arch.name, kind: arch.kind, title: 'architect' }); seen.add(rootId); }
|
||||
roster
|
||||
.filter((r) => r !== arch)
|
||||
.sort((a, b) => (ROLE_ORDER[a.role] ?? 9) - (ROLE_ORDER[b.role] ?? 9) || a.name.localeCompare(b.name))
|
||||
.forEach((r, i) => {
|
||||
const id = 'n:' + r.name + ':' + i;
|
||||
nodes.push({ id, label: r.name, agent: r.name, kind: r.kind, parentId: rootId, title: r.role });
|
||||
});
|
||||
return { nodes, rootId };
|
||||
}
|
||||
|
||||
function attachBusy(cwd: string, roster: RosterAgent[]): RosterAgent[] {
|
||||
const tasks = listTasks(cwd) as TaskRow[];
|
||||
const busyByAgent = new Map<string, TaskRow>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
return roster.map((a) => ({ ...a, busyTask: busyByAgent.get(a.name) }));
|
||||
}
|
||||
|
||||
function statusLine(a: RosterAgent): string {
|
||||
const busy = a.busyTask;
|
||||
if (busy) {
|
||||
return `<div style="margin-top:10px;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</a>
|
||||
</div>`;
|
||||
}
|
||||
return `<div style="margin-top:10px;display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px;">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:var(--green);"></span> free
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
? `<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:var(--raised);border:1px solid var(--border);${opts.architectRing ? `box-shadow:0 0 0 2px var(--bg),0 0 0 4px ${meta.color};` : ''}">${providerLogo(a.kind, 19)}</span>`
|
||||
: agentAvatar(a.name, { architectRing: opts.architectRing, size: 34 });
|
||||
|
||||
const model = a.model
|
||||
? `<span style="font-family:var(--font-mono);font-size:11px;color:${meta ? meta.color : 'var(--muted)'};">${escapeHtml(a.model)}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="agent-card" style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px 14px;min-width:212px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
${avatar}
|
||||
<div style="min-width:0;">
|
||||
<div style="font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(a.name)}</div>
|
||||
${model}
|
||||
</div>
|
||||
</div>
|
||||
${a.description ? `<div style="margin-top:8px;color:var(--muted);font-size:11px;line-height:1.4;">${escapeHtml(a.description)}</div>` : ''}
|
||||
${statusLine(a)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** 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 `
|
||||
<div class="provider-group" style="background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:10px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding-bottom:4px;">
|
||||
${providerLogo(kind, 18)}
|
||||
<span style="font-weight:600;font-size:13px;">${meta ? escapeHtml(meta.name) : escapeHtml(kind)}</span>
|
||||
<span style="flex:1;"></span>
|
||||
<span style="color:var(--muted);font-size:11px;font-family:var(--font-mono);">${busy}/${agents.length} busy</span>
|
||||
</div>
|
||||
${agents.map((a) => agentCard(a, { avatar: 'monogram' })).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function tierLabel(text: string): string {
|
||||
return `<div data-role="${escapeHtml(text.toLowerCase().replace(/s$/, ''))}" style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted);background:var(--bg);padding:2px 12px;border:1px solid var(--border);border-radius:999px;">${escapeHtml(text)}</div>`;
|
||||
function monogram(name: string): string {
|
||||
const clean = name.replace(/[^A-Za-z0-9]+/g, ' ').trim();
|
||||
return escapeHtml(clean ? clean.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase() : '?');
|
||||
}
|
||||
|
||||
export function renderTeamHtml(cwd: string): string {
|
||||
const config = loadConfig(cwd);
|
||||
const roster = attachBusy(cwd, gatherRoster(cwd));
|
||||
const { nodes, rootId } = resolveOrg(config);
|
||||
const states = computeStates(cwd);
|
||||
|
||||
const byRole = new Map<string, RosterAgent[]>();
|
||||
for (const a of roster) {
|
||||
const list = byRole.get(a.role) ?? [];
|
||||
list.push(a);
|
||||
byRole.set(a.role, list);
|
||||
const byId = new Map<string, OrgNode>();
|
||||
for (const n of nodes) byId.set(n.id, n);
|
||||
const childrenOf = new Map<string, OrgNode[]>();
|
||||
for (const n of nodes) {
|
||||
if (n.parentId) {
|
||||
const l = childrenOf.get(n.parentId) ?? [];
|
||||
l.push(n);
|
||||
childrenOf.set(n.parentId, l);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
const agentCfg = config.agents ?? {};
|
||||
|
||||
// Group implementers by provider, ordered anthropic -> openai -> moonshot -> rest.
|
||||
const byProvider = new Map<string, RosterAgent[]>();
|
||||
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));
|
||||
function renderNode(n: OrgNode): string {
|
||||
const linked = n.agent ? agentCfg[n.agent] : undefined;
|
||||
const kind = n.kind ?? linked?.kind;
|
||||
const meta = providerMeta(kind);
|
||||
const icon = (kind ? providerLogo(kind, 18) : '') || `<span class="mono">${monogram(n.label)}</span>`;
|
||||
const role = n.role ?? linked?.role;
|
||||
const model = linked?.model;
|
||||
const modelColor = meta ? meta.color : 'var(--muted)';
|
||||
const sub = [role ? escapeHtml(role) : '', model ? `<span class="node-model" style="color:${modelColor}">${escapeHtml(model)}</span>` : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const title = n.title ?? linked?.description ?? '';
|
||||
const st = n.agent ? states.get(n.agent) : undefined;
|
||||
const stateClass = st ? ` ${st.state}` : '';
|
||||
const badge = st ? `<span class="node-badge ${st.state === 'active' ? 'active' : 'review'}">${st.state === 'active' ? 'active' : 'review'}</span>` : '<span class="node-badge"></span>';
|
||||
const status = st
|
||||
? st.state === 'active'
|
||||
? `<span class="st-dot busy"></span><a class="st-link" href="/tasks/${escapeHtml(st.task.id)}">${escapeHtml(st.task.id)}</a>`
|
||||
: `<span class="st-dot review"></span><a class="st-link review" href="/tasks/${escapeHtml(st.task.id)}">${escapeHtml(st.task.id)}</a>`
|
||||
: `<span class="st-dot"></span><span class="st-free">free</span>`;
|
||||
return `<div class="node agent-card${stateClass}" data-node-id="${escapeHtml(n.id)}" data-parent="${escapeHtml(n.parentId ?? '')}"${n.agent ? ` data-agent="${escapeHtml(n.agent)}"` : ''}>
|
||||
<div class="node-top">
|
||||
<span class="node-icon" style="--pv:${modelColor}">${icon}</span>
|
||||
<span class="node-id">
|
||||
<span class="node-name" title="Click the pencil (or double-click) to rename">${escapeHtml(n.label)}</span>
|
||||
${sub ? `<span class="node-role">${sub}</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div class="node-actions">
|
||||
<button class="node-edit" type="button" title="Rename" aria-label="Rename">✎</button>
|
||||
${title ? `<button class="node-info-btn" type="button" aria-label="Info">i</button>` : ''}
|
||||
</div>
|
||||
${badge}
|
||||
${title ? `<div class="node-info" hidden>${escapeHtml(title)}</div>` : ''}
|
||||
<div class="agent-status">${status}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const architectTier = architects.length
|
||||
? `<div class="tier"><div class="tier-row">${architects.map((a) => agentCard(a, { avatar: 'logo', architectRing: true })).join('')}</div>${tierLabel('Architect')}</div>`
|
||||
: '';
|
||||
|
||||
const implementerTier = providerKeys.length
|
||||
? `<div class="tier">${tierLabel('Implementers')}<div class="provider-row">${providerKeys.map((k) => providerGroup(k, byProvider.get(k)!)).join('')}</div></div>`
|
||||
: '';
|
||||
|
||||
const testerTier = testers.length
|
||||
? `<div class="tier">${tierLabel('Testers')}<div class="tier-row">${testers.map((a) => agentCard(a, { avatar: 'logo' })).join('')}</div></div>`
|
||||
: '';
|
||||
|
||||
const connector = '<div class="connector-down" aria-hidden="true"></div>';
|
||||
const tiers = [architectTier, implementerTier, testerTier].filter(Boolean).join(connector);
|
||||
function renderSubtree(id: string): string {
|
||||
const n = byId.get(id);
|
||||
if (!n) return '';
|
||||
const kids = (childrenOf.get(id) ?? []).slice().sort((a, b) => {
|
||||
const ra = a.agent ? agentCfg[a.agent]?.role : undefined;
|
||||
const rb = b.agent ? agentCfg[b.agent]?.role : undefined;
|
||||
return (ROLE_ORDER[ra ?? ''] ?? 5) - (ROLE_ORDER[rb ?? ''] ?? 5) || a.label.localeCompare(b.label);
|
||||
});
|
||||
// Wrap a wide row of leaf children into a compact grid so a broad subtree
|
||||
// doesn't spread the whole tree — keeps upper levels close together.
|
||||
const allLeaves = kids.every((k) => !(childrenOf.get(k.id)?.length));
|
||||
const wrap = allLeaves && kids.length > 4;
|
||||
const cols = wrap ? Math.min(4, Math.ceil(Math.sqrt(kids.length))) : 0;
|
||||
const childrenHtml = kids.length
|
||||
? `<div class="children${wrap ? ' wrap' : ''}"${wrap ? ` style="--cols:${cols}"` : ''}>${kids.map((k) => renderSubtree(k.id)).join('')}</div>`
|
||||
: '';
|
||||
return `<div class="subtree">${renderNode(n)}${childrenHtml}</div>`;
|
||||
}
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
@ -184,30 +153,405 @@ export function renderTeamHtml(cwd: string): string {
|
||||
<title>AgentHub Team</title>
|
||||
<style>
|
||||
${designTokensCss()}
|
||||
body { overflow-x: hidden; }
|
||||
|
||||
.tree { display:flex; flex-direction:column; align-items:center; gap:0; padding:8px 0 32px; }
|
||||
.tier { display:flex; flex-direction:column; align-items:center; gap:14px; width:100%; }
|
||||
.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; }
|
||||
.scroll { overflow-x: auto; padding-bottom: 12px; }
|
||||
.chart { position: relative; display: inline-flex; min-width: 100%; justify-content: center; padding: 12px 24px 40px; }
|
||||
.edges { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; overflow: visible; }
|
||||
.subtree { position: relative; z-index: 1; display: inline-flex; flex-direction: column; align-items: center; }
|
||||
.children { display: flex; flex-direction: row; align-items: flex-start; justify-content: center; gap: 11px; margin-top: 42px; }
|
||||
/* Wide leaf rows collapse into a compact grid (connectors drop from a fixed bus). */
|
||||
.children.wrap { display: grid; grid-template-columns: repeat(var(--cols, 3), 182px); justify-content: center; column-gap: 11px; row-gap: 46px; }
|
||||
.children.wrap .subtree { width: 182px; }
|
||||
|
||||
.agent-card { transition: border-color 150ms ease, transform 150ms ease; }
|
||||
.agent-card:hover { border-color: rgba(88,166,255,0.5); transform: translateY(-1px); }
|
||||
.team-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 6px 8px; }
|
||||
.tb-spacer { flex: 1; }
|
||||
.fit-btn { min-height: 30px; padding: 0 13px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--muted); font: 600 12px/1 var(--font-sans); cursor: pointer; transition: color 150ms, border-color 150ms; white-space: nowrap; }
|
||||
.fit-btn:hover { color: var(--text); border-color: var(--accent); }
|
||||
.fit-btn.flow { color: #adf2c7; border-color: rgba(34,197,94,.4); }
|
||||
.fit-btn.flow:hover { border-color: var(--green); }
|
||||
.fit-btn.flow[disabled] { opacity: .6; cursor: default; }
|
||||
.flow-caption { font: 12px/1.3 var(--font-mono); color: var(--text); background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--green); border-radius: 8px; padding: 6px 11px; max-width: 62%; }
|
||||
.flow-caption.review { border-left-color: var(--status-review); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.provider-row { flex-direction:column; align-items:center; gap:14px; }
|
||||
.provider-group { max-width:none; width:100%; }
|
||||
.node {
|
||||
position: relative;
|
||||
width: 182px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(0,0,0,.10)), var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
transition: border-color 220ms ease, transform 180ms ease, box-shadow 320ms ease, background 320ms ease;
|
||||
animation: nodeEnter 300ms cubic-bezier(.2,.7,.2,1) both;
|
||||
}
|
||||
@keyframes nodeEnter { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
.node:hover { border-color: rgba(88,166,255,.5); }
|
||||
.node-top { display: flex; align-items: center; gap: 10px; padding-right: 34px; }
|
||||
.node-actions { position: absolute; top: 9px; right: 10px; display: flex; align-items: center; gap: 5px; z-index: 4; }
|
||||
.node-icon { width: 32px; height: 32px; flex: 0 0 auto; display: inline-grid; place-items: center; border-radius: 9px; background: var(--raised); border: 1px solid var(--border); }
|
||||
.node-icon .mono { font: 800 11px/1 var(--font-mono); color: var(--pv, var(--muted)); }
|
||||
.node-id { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
||||
.node-name { font-weight: 700; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; outline: none; border-radius: 4px; }
|
||||
.node-edit { flex: 0 0 auto; width: 18px; height: 18px; border-radius: 50%; border: 1px solid var(--border); background: var(--raised); color: var(--muted); font: 10px/1 var(--font-sans); cursor: pointer; align-self: flex-start; opacity: 0; transition: opacity 140ms, color 140ms, border-color 140ms; }
|
||||
.node:hover .node-edit { opacity: 1; }
|
||||
.node-edit:hover { color: var(--text); border-color: var(--accent); }
|
||||
.node-name[contenteditable="true"] { background: rgba(88,166,255,.12); box-shadow: 0 0 0 1px rgba(88,166,255,.5); padding: 0 4px; cursor: text; }
|
||||
.node-role { display: block; max-width: 100%; font: 10.5px/1.2 var(--font-mono); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.node-model { text-transform: none; letter-spacing: 0; }
|
||||
|
||||
/* Floating status chip that straddles the top-right edge (Paperclip style). */
|
||||
.node-badge { position: absolute; top: -9px; right: 12px; z-index: 3; font: 700 9.5px/1 var(--font-mono); text-transform: capitalize; letter-spacing: .02em; padding: 4px 9px; border-radius: 999px; }
|
||||
.node-badge:empty { display: none; }
|
||||
.node-badge.active { color: #15803d; background: #ffffff; box-shadow: 0 2px 12px rgba(34,197,94,.45); }
|
||||
.node-badge.review { color: #7a5a12; background: #ffffff; box-shadow: 0 2px 12px rgba(210,153,34,.4); }
|
||||
|
||||
.node-info-btn { flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; border: 1px solid var(--border); background: var(--raised); color: var(--muted); font: 700 11px/1 var(--font-mono); cursor: pointer; align-self: flex-start; transition: color 150ms, border-color 150ms; }
|
||||
.node-info-btn:hover { color: var(--text); border-color: var(--accent); }
|
||||
.node-info { margin-top: 9px; padding: 8px 10px; font-size: 11.5px; line-height: 1.45; color: var(--muted); background: var(--bg); border: 1px solid var(--border); border-radius: 9px; }
|
||||
|
||||
.agent-status { margin-top: 9px; 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(--green); box-shadow: 0 0 0 3px rgba(34,197,94,.16); animation: dotPulse 1.5s ease-in-out infinite; }
|
||||
.st-dot.review { background: var(--status-review); box-shadow: 0 0 0 3px rgba(210,153,34,.16); }
|
||||
@keyframes dotPulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
|
||||
.st-free { color: var(--muted); }
|
||||
.st-link { color: var(--green); text-decoration: none; font-family: var(--font-mono); font-size: 12px; }
|
||||
.st-link.review { color: var(--status-review); }
|
||||
.st-link:hover { text-decoration: underline; }
|
||||
|
||||
/* Active card: a green highlight sweeps along the border L→R while the task runs. */
|
||||
.node::before {
|
||||
content: ''; position: absolute; inset: 0; border-radius: 14px; padding: 1.6px;
|
||||
background: linear-gradient(90deg, rgba(34,197,94,0) 32%, #22c55e 50%, rgba(34,197,94,0) 68%);
|
||||
background-size: 260% 100%; background-position: -50% 0;
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
mask-composite: exclude;
|
||||
opacity: 0; transition: opacity 340ms ease; pointer-events: none; z-index: 2;
|
||||
}
|
||||
.node.active { border-color: rgba(34,197,94,.4); box-shadow: 0 0 20px rgba(34,197,94,.10); }
|
||||
.node.active::before { opacity: 1; animation: borderSweep 2.6s linear infinite; }
|
||||
@keyframes borderSweep { 0% { background-position: -50% 0; } 100% { background-position: 150% 0; } }
|
||||
.node.reviewing { border-color: rgba(210,153,34,.55); box-shadow: 0 0 0 1px rgba(210,153,34,.45), 0 0 20px rgba(210,153,34,.12); }
|
||||
/* Arrival + completion glows live on a separate ::after overlay (opacity only)
|
||||
so they never fight the node's own box-shadow transition — no twitch. */
|
||||
.node::after {
|
||||
content: ''; position: absolute; inset: 0; border-radius: 14px; pointer-events: none;
|
||||
box-shadow: 0 0 0 1px rgba(34,197,94,.55), 0 0 22px rgba(34,197,94,.25);
|
||||
opacity: 0;
|
||||
}
|
||||
.node.arrive::after { animation: glowInOut 700ms ease; }
|
||||
.node.done-flash::after { animation: glowInOut 950ms ease; }
|
||||
@keyframes glowInOut { 0% { opacity: 0; } 38% { opacity: 1; } 100% { opacity: 0; } }
|
||||
|
||||
.edge-base { fill: none; stroke: var(--border); stroke-width: 1.5; animation: edgeBreath 4.5s ease-in-out infinite; }
|
||||
@keyframes edgeBreath { 0%,100% { opacity: .35; } 50% { opacity: .62; } }
|
||||
.edge-pulse { fill: none; stroke-width: 3.5; stroke-linecap: round; filter: url(#pulseGlow); }
|
||||
.edge-pulse.down { stroke: var(--green); }
|
||||
.edge-pulse.up { stroke: var(--status-review); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { .edge-pulse, .edge-base { animation: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${pageHeader(config.projectName, 'team')}
|
||||
<main class="tree" id="tree">
|
||||
${tiers}
|
||||
</main>
|
||||
<div class="team-toolbar">
|
||||
<span class="flow-caption" id="flowCaption" hidden></span>
|
||||
<span class="tb-spacer"></span>
|
||||
<button class="fit-btn flow" id="flowBtn" type="button" title="Play the delegation-flow animation">▶ Flow</button>
|
||||
<button class="fit-btn" id="fitBtn" type="button" title="Scale the whole chart to fit the screen">Fit</button>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<main class="chart" id="chart">
|
||||
<svg class="edges" id="edges" aria-hidden="true">
|
||||
<defs>
|
||||
<filter id="pulseGlow" x="-60%" y="-60%" width="220%" height="220%">
|
||||
<feGaussianBlur stdDeviation="2.6" result="b" />
|
||||
<feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
${renderSubtree(rootId)}
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
${liveTimerJs()}
|
||||
(function() {
|
||||
var NS = 'http://www.w3.org/2000/svg';
|
||||
var SEG = 720, EDGE_DUR = 1850;
|
||||
var connDot = document.getElementById('conn-dot');
|
||||
var edgeMap = {}; // nodeId -> { fwd, rev } path from its parent
|
||||
var parentOf = {}; // nodeId -> parent nodeId
|
||||
var taskState = {}, baseline = false;
|
||||
|
||||
function setConn(ok) { if (connDot) { connDot.style.background = ok ? 'var(--green)' : 'var(--status-review)'; connDot.title = ok ? 'connected' : 'reconnecting'; } }
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
function ago(iso) { var t = Date.parse(iso); if (isNaN(t)) return ''; var s = Math.max(0, Math.floor((Date.now()-t)/1000)); if (s<60) return s+'s'; var m=Math.floor(s/60); if (m<60) return m+'m'; var h=Math.floor(m/60); if (h<24) return h+'h'; return Math.floor(h/24)+'d'; }
|
||||
function centerTop(el, box) { var r = el.getBoundingClientRect(); return { x: r.left - box.left + r.width/2, y: r.top - box.top }; }
|
||||
function centerBottom(el, box) { var r = el.getBoundingClientRect(); return { x: r.left - box.left + r.width/2, y: r.bottom - box.top }; }
|
||||
function nodeById(id) { return document.querySelector('.node[data-node-id="' + (id||'').replace(/"/g,'') + '"]'); }
|
||||
function flash(el) { if (!el) return; el.classList.remove('arrive'); void el.offsetWidth; el.classList.add('arrive'); setTimeout(function(){ el.classList.remove('arrive'); }, 700); }
|
||||
function doneFlashAgent(agent) {
|
||||
document.querySelectorAll('.node[data-agent="' + (agent || '').replace(/"/g, '') + '"]').forEach(function(n) {
|
||||
n.classList.remove('done-flash'); void n.offsetWidth; n.classList.add('done-flash');
|
||||
setTimeout(function(){ n.classList.remove('done-flash'); }, 900);
|
||||
});
|
||||
}
|
||||
|
||||
function layoutEdges() {
|
||||
var svg = document.getElementById('edges');
|
||||
var chart = document.getElementById('chart');
|
||||
if (!svg || !chart) return;
|
||||
var box = chart.getBoundingClientRect();
|
||||
svg.setAttribute('viewBox', '0 0 ' + box.width + ' ' + box.height);
|
||||
var defs = svg.querySelector('defs'); svg.innerHTML = ''; if (defs) svg.appendChild(defs);
|
||||
edgeMap = {}; parentOf = {};
|
||||
Array.prototype.slice.call(document.querySelectorAll('.node[data-node-id]')).forEach(function(n) {
|
||||
var pid = n.getAttribute('data-parent');
|
||||
if (!pid) return;
|
||||
var parent = nodeById(pid);
|
||||
if (!parent) return;
|
||||
parentOf[n.getAttribute('data-node-id')] = pid;
|
||||
var s = centerBottom(parent, box), e = centerTop(n, box);
|
||||
// Sharp org-chart elbow: down from the parent to the shared bus, across, down to the child.
|
||||
// Siblings share the same top Y, so their bus Y matches → one clean horizontal bus.
|
||||
var busY = Math.round(s.y + 20);
|
||||
var sx = Math.round(s.x), ex = Math.round(e.x);
|
||||
var fwd = 'M ' + sx + ' ' + Math.round(s.y) + ' L ' + sx + ' ' + busY + ' L ' + ex + ' ' + busY + ' L ' + ex + ' ' + Math.round(e.y);
|
||||
var rev = 'M ' + ex + ' ' + Math.round(e.y) + ' L ' + ex + ' ' + busY + ' L ' + sx + ' ' + busY + ' L ' + sx + ' ' + Math.round(s.y);
|
||||
edgeMap[n.getAttribute('data-node-id')] = { fwd: fwd, rev: rev };
|
||||
var base = document.createElementNS(NS, 'path');
|
||||
base.setAttribute('class', 'edge-base'); base.setAttribute('d', fwd);
|
||||
svg.appendChild(base);
|
||||
});
|
||||
}
|
||||
|
||||
function firePulseEdge(nodeId, dir, doFlash) {
|
||||
var e = edgeMap[nodeId]; var svg = document.getElementById('edges');
|
||||
if (!e || !svg) return;
|
||||
var p = document.createElementNS(NS, 'path');
|
||||
p.setAttribute('class', 'edge-pulse ' + dir);
|
||||
p.setAttribute('d', dir === 'up' ? e.rev : e.fwd);
|
||||
svg.appendChild(p);
|
||||
var len = p.getTotalLength();
|
||||
// One dash with an effectively infinite gap → no second dash can wrap
|
||||
// around onto the path. It travels origin(0) → target(len) → off, and
|
||||
// fill:forwards holds the off-path end so it never snaps back to origin.
|
||||
p.style.strokeDasharray = '20 100000';
|
||||
p.style.strokeDashoffset = '0';
|
||||
var done = function() {
|
||||
try { p.remove(); } catch (_) {}
|
||||
// Only the final destination lights up — waypoints (e.g. CEO on a
|
||||
// peer-to-peer handoff) are just passed through, not "involved".
|
||||
if (doFlash) flash(dir === 'up' ? nodeById(parentOf[nodeId]) : nodeById(nodeId));
|
||||
};
|
||||
if (p.animate) {
|
||||
// ease-in-out: slower leaving the origin card and arriving at the target's last branch.
|
||||
p.animate([{ strokeDashoffset: 0 }, { strokeDashoffset: -(len + 24) }],
|
||||
{ duration: EDGE_DUR, easing: 'cubic-bezier(.5,0,.5,1)', fill: 'forwards' }).onfinish = done;
|
||||
} else setTimeout(done, EDGE_DUR);
|
||||
}
|
||||
|
||||
// The delegation hub = the architect (the root's single child), so real
|
||||
// pulses originate at the architect, not the CEO above it.
|
||||
function hubId() {
|
||||
var root = document.querySelector('.node[data-parent=""]') || document.querySelector('.node[data-node-id]');
|
||||
if (!root) return null;
|
||||
var rid = root.getAttribute('data-node-id');
|
||||
var kids = document.querySelectorAll('.node[data-parent="' + rid + '"]');
|
||||
return kids.length === 1 ? kids[0].getAttribute('data-node-id') : rid;
|
||||
}
|
||||
function pulseToAgent(agent, dir) {
|
||||
var hub = hubId();
|
||||
if (!hub) return;
|
||||
document.querySelectorAll('.node[data-agent="' + (agent||'').replace(/"/g,'') + '"]').forEach(function(n) {
|
||||
var id = n.getAttribute('data-node-id');
|
||||
if (id === hub) return;
|
||||
if (dir === 'down') pulseBetween(hub, id); // architect → agent (delegation)
|
||||
else pulseBetween(id, hub); // agent → architect (review)
|
||||
});
|
||||
}
|
||||
|
||||
function applyState(card, state, task) {
|
||||
card.classList.toggle('active', state === 'active');
|
||||
card.classList.toggle('reviewing', state === 'reviewing');
|
||||
var badge = card.querySelector('.node-badge');
|
||||
if (badge) { badge.className = 'node-badge' + (state ? ' ' + (state === 'active' ? 'active' : 'review') : ''); badge.textContent = state === 'active' ? 'active' : state === 'reviewing' ? 'review' : ''; }
|
||||
var st = card.querySelector('.agent-status');
|
||||
if (st) {
|
||||
if (state === 'active' && task) st.innerHTML = '<span class="st-dot busy"></span><a class="st-link" href="/tasks/' + esc(task.id) + '">' + esc(task.id) + ' \\u00b7 ' + esc(ago(task.updatedAt)) + '</a>';
|
||||
else if (state === 'reviewing' && task) st.innerHTML = '<span class="st-dot review"></span><a class="st-link review" href="/tasks/' + esc(task.id) + '">' + esc(task.id) + ' \\u00b7 ' + esc(ago(task.updatedAt)) + '</a>';
|
||||
else st.innerHTML = '<span class="st-dot"></span><span class="st-free">free</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function sync() {
|
||||
var tasks;
|
||||
try { tasks = await fetch('/tasks', { headers: { accept: 'application/json' } }).then(function(r){ return r.json(); }); }
|
||||
catch (e) { setConn(false); return; }
|
||||
setConn(true);
|
||||
if (baseline) {
|
||||
tasks.forEach(function(t) {
|
||||
var pv = taskState[t.id]; var agent = t.assignedTo || (pv && pv.assignedTo); if (!agent) return;
|
||||
var was = pv ? pv.status : null;
|
||||
if (t.status === 'in_progress' && was !== 'in_progress') pulseToAgent(agent, 'down');
|
||||
else if (t.status === 'review' && was !== 'review') pulseToAgent(agent, 'up');
|
||||
else if (t.status === 'open' && pv && t.assignedTo && t.assignedTo !== pv.assignedTo) pulseToAgent(agent, 'down');
|
||||
else if ((t.status === 'done' || t.status === 'cancelled') && (was === 'in_progress' || was === 'review')) doneFlashAgent(agent);
|
||||
});
|
||||
}
|
||||
var next = {}; tasks.forEach(function(t) { next[t.id] = { status: t.status, assignedTo: t.assignedTo }; });
|
||||
taskState = next; baseline = true;
|
||||
|
||||
var active = {}, review = {};
|
||||
tasks.forEach(function(t) {
|
||||
if (!t.assignedTo) return;
|
||||
if (t.status === 'in_progress') { if (!active[t.assignedTo] || t.updatedAt > active[t.assignedTo].updatedAt) active[t.assignedTo] = t; }
|
||||
else if (t.status === 'review') { if (!review[t.assignedTo] || t.updatedAt > review[t.assignedTo].updatedAt) review[t.assignedTo] = t; }
|
||||
});
|
||||
document.querySelectorAll('.node[data-agent]').forEach(function(card) {
|
||||
var name = card.getAttribute('data-agent');
|
||||
if (active[name]) applyState(card, 'active', active[name]);
|
||||
else if (review[name]) applyState(card, 'reviewing', review[name]);
|
||||
else applyState(card, null, null);
|
||||
});
|
||||
}
|
||||
|
||||
// Info toggle.
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest && e.target.closest('.node-info-btn');
|
||||
if (!btn) return;
|
||||
var info = btn.closest('.node').querySelector('.node-info');
|
||||
if (info) { info.hidden = !info.hidden; setTimeout(layoutEdges, 30); }
|
||||
});
|
||||
|
||||
// Inline rename → PATCH /org/:id (pencil click OR double-click the name).
|
||||
function startEdit(name) {
|
||||
if (!name) return;
|
||||
name.setAttribute('contenteditable', 'true'); name.focus();
|
||||
try { document.getSelection().selectAllChildren(name); } catch (_) {}
|
||||
}
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest && e.target.closest('.node-edit');
|
||||
if (btn) startEdit(btn.closest('.node').querySelector('.node-name'));
|
||||
});
|
||||
document.addEventListener('dblclick', function(e) {
|
||||
var name = e.target.closest && e.target.closest('.node-name');
|
||||
if (name) startEdit(name);
|
||||
});
|
||||
function commitRename(name) {
|
||||
var card = name.closest('.node'); if (!card) return;
|
||||
var id = card.getAttribute('data-node-id');
|
||||
var label = name.textContent.trim().slice(0, 60);
|
||||
name.removeAttribute('contenteditable');
|
||||
if (!label) { name.textContent = name.getAttribute('data-prev') || label; return; }
|
||||
fetch('/org/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label: label }) })
|
||||
.then(function(r){ if (!r.ok) throw 0; return r.json(); })
|
||||
.then(function(res){ name.textContent = res.label; })
|
||||
.catch(function(){ /* keep the typed value optimistically */ });
|
||||
}
|
||||
document.addEventListener('keydown', function(e) {
|
||||
var name = e.target.closest && e.target.closest('.node-name[contenteditable="true"]');
|
||||
if (!name) return;
|
||||
if (e.key === 'Enter') { e.preventDefault(); name.blur(); }
|
||||
if (e.key === 'Escape') { name.textContent = name.getAttribute('data-prev') || name.textContent; name.blur(); }
|
||||
});
|
||||
document.addEventListener('focusin', function(e) { var n = e.target.closest && e.target.closest('.node-name'); if (n) n.setAttribute('data-prev', n.textContent.trim()); });
|
||||
document.addEventListener('blur', function(e) { var n = e.target.closest && e.target.closest('.node-name[contenteditable="true"]'); if (n) commitRename(n); }, true);
|
||||
|
||||
function centerScroll() {
|
||||
var sc = document.querySelector('.scroll');
|
||||
if (sc) sc.scrollLeft = Math.max(0, (sc.scrollWidth - sc.clientWidth) / 2);
|
||||
}
|
||||
// Fit-to-screen: CSS zoom reflows (so scrollWidth shrinks), unlike transform.
|
||||
var fitted = false;
|
||||
function toggleFit() {
|
||||
var sc = document.querySelector('.scroll'), chart = document.getElementById('chart'), btn = document.getElementById('fitBtn');
|
||||
if (!sc || !chart) return;
|
||||
if (!fitted) {
|
||||
chart.style.zoom = '1';
|
||||
var natural = chart.scrollWidth || chart.getBoundingClientRect().width;
|
||||
var k = Math.min(1, (sc.clientWidth - 10) / natural);
|
||||
chart.style.zoom = String(k);
|
||||
fitted = true; if (btn) btn.textContent = '100%';
|
||||
setTimeout(layoutEdges, 40);
|
||||
} else {
|
||||
chart.style.zoom = '1'; fitted = false; if (btn) btn.textContent = 'Fit';
|
||||
setTimeout(function(){ layoutEdges(); centerScroll(); }, 40);
|
||||
}
|
||||
}
|
||||
var fb = document.getElementById('fitBtn');
|
||||
if (fb) fb.addEventListener('click', toggleFit);
|
||||
|
||||
// ── Flow demo: choreographed pulses that walk the delegation lifecycle ──
|
||||
function sleep(ms) { return new Promise(function(r){ setTimeout(r, ms); }); }
|
||||
function ancestors(id) { var a = [id], c = parentOf[id]; while (c) { a.push(c); c = parentOf[c]; } return a; }
|
||||
// Pulse from one node to another along the tree (up to the common ancestor,
|
||||
// then down). Each edge is coloured by its real direction. Returns duration.
|
||||
function pulseBetween(fromId, toId) {
|
||||
if (!edgeMap || (!edgeMap[fromId] && !edgeMap[toId] && fromId !== toId)) { /* still fire best-effort */ }
|
||||
var A = ancestors(fromId), B = ancestors(toId);
|
||||
var Bidx = {}; B.forEach(function(id, i) { Bidx[id] = i; });
|
||||
var lca = null, ai = 0;
|
||||
for (var i = 0; i < A.length; i++) { if (Bidx[A[i]] != null) { lca = A[i]; ai = i; break; } }
|
||||
if (lca == null) return EDGE_DUR;
|
||||
var seq = [];
|
||||
for (var u = 0; u < ai; u++) seq.push({ nodeId: A[u], dir: 'up' }); // from → LCA
|
||||
for (var d = Bidx[lca] - 1; d >= 0; d--) seq.push({ nodeId: B[d], dir: 'down' }); // LCA → to
|
||||
seq.forEach(function(step, k) { setTimeout(function(){ firePulseEdge(step.nodeId, step.dir, k === seq.length - 1); }, k * SEG); });
|
||||
return Math.max(EDGE_DUR, seq.length * SEG + EDGE_DUR);
|
||||
}
|
||||
|
||||
var DEMO = [
|
||||
{ from: 'claude', to: 'po-native', cap: 'claude definiert den Task und delegiert an PO Native' },
|
||||
{ from: 'po-native', to: 'backyard', cap: 'PO Native delegiert an den richtigen Agenten (backyard)' },
|
||||
{ from: 'backyard', to: 'po-native', cap: 'backyard liefert die Arbeit zurück an PO Native', rev: true },
|
||||
{ from: 'po-native', to: 'claude', cap: 'PO Native validiert → zurück zum Architekt-Review', rev: true },
|
||||
{ from: 'claude', to: 'cicd', cap: 'Architekt: Review OK → an CI/CD (sonst: reopen an den Agenten)' },
|
||||
{ from: 'cicd', to: 'ahmed', cap: 'CI/CD gibt an ahmed zum Testen' },
|
||||
{ from: 'ahmed', to: 'cicd', cap: 'ahmed meldet das Testergebnis zurück an CI/CD', rev: true },
|
||||
{ from: 'cicd', to: 'claude', cap: 'CI/CD zurück an Architekt → Approve & Push', rev: true },
|
||||
{ from: 'claude', to: 'strategist', cap: 'Manche Tasks: Architekt zieht den Strategist direkt hinzu (kein CEO)' },
|
||||
{ from: 'claude', to: 'ceo', cap: 'Erst am Ende: Architekt gibt dem CEO den finalen Report', rev: true }
|
||||
];
|
||||
function showCaption(text, review) {
|
||||
var el = document.getElementById('flowCaption');
|
||||
if (!el) return;
|
||||
if (!text) { el.hidden = true; el.textContent = ''; return; }
|
||||
el.hidden = false; el.className = 'flow-caption' + (review ? ' review' : ''); el.textContent = text;
|
||||
}
|
||||
var demoRunning = false;
|
||||
async function runDemo() {
|
||||
if (demoRunning) return;
|
||||
demoRunning = true;
|
||||
var btn = document.getElementById('flowBtn');
|
||||
if (btn) { btn.setAttribute('disabled', 'true'); }
|
||||
layoutEdges(); // make sure edge geometry is current
|
||||
for (var i = 0; i < DEMO.length; i++) {
|
||||
var s = DEMO[i];
|
||||
showCaption((i + 1) + '/' + DEMO.length + ' · ' + s.cap, s.rev);
|
||||
var dur = pulseBetween(s.from, s.to);
|
||||
await sleep(dur + 850);
|
||||
}
|
||||
showCaption('');
|
||||
if (btn) btn.removeAttribute('disabled');
|
||||
demoRunning = false;
|
||||
}
|
||||
var flowBtn = document.getElementById('flowBtn');
|
||||
if (flowBtn) flowBtn.addEventListener('click', runDemo);
|
||||
|
||||
var rt; function relayout() { clearTimeout(rt); rt = setTimeout(function(){ if (fitted) { fitted = false; toggleFit(); } else layoutEdges(); }, 120); }
|
||||
window.addEventListener('resize', relayout);
|
||||
layoutEdges(); centerScroll();
|
||||
setTimeout(function(){ layoutEdges(); centerScroll(); }, 350);
|
||||
sync(); setInterval(sync, 1500);
|
||||
if ('EventSource' in window) {
|
||||
var es = new EventSource('/events');
|
||||
es.onopen = function(){ setConn(true); };
|
||||
es.onmessage = function(){ sync(); };
|
||||
es.onerror = function(){ setConn(false); };
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@ -18,6 +18,29 @@ describe('memoryService', () => {
|
||||
expect(listMemory(cwd)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('searches literal task IDs and other FTS5-special strings without throwing', () => {
|
||||
addMemory(cwd, {
|
||||
title: 'TSK-0037 result',
|
||||
category: 'implementation',
|
||||
content: 'Linked HOF-0030 and a:b plus foo-bar all belong to this note.',
|
||||
});
|
||||
|
||||
expect(searchMemory(cwd, 'TSK-0037')).toHaveLength(1);
|
||||
expect(searchMemory(cwd, 'HOF-0030')).toHaveLength(1);
|
||||
expect(searchMemory(cwd, 'a:b')).toHaveLength(1);
|
||||
expect(searchMemory(cwd, 'foo-bar')).toHaveLength(1);
|
||||
expect(searchMemory(cwd, 'TTL')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps normal word search functional after FTS5 quoting', () => {
|
||||
addMemory(cwd, { title: 'DNS cache', category: 'technical', content: 'Use TTL' });
|
||||
addMemory(cwd, { title: 'Other note', category: 'technical', content: 'No cache keyword here' });
|
||||
|
||||
const results = searchMemory(cwd, 'TTL');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].title).toBe('DNS cache');
|
||||
});
|
||||
|
||||
it('stores relatedTasks in the memory object', () => {
|
||||
const m = addMemory(cwd, {
|
||||
title: 'TTL lesson',
|
||||
|
||||
@ -189,21 +189,54 @@ describe('server routes', () => {
|
||||
expect(html).toContain('who-cell');
|
||||
});
|
||||
|
||||
it('board HTML contains activity timeline expansion logic', async () => {
|
||||
it('board HTML keeps cards slim and links to task detail pages', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/board' });
|
||||
const html = res.payload;
|
||||
// Cards must be clickable and carry a data-id attribute
|
||||
// Cards carry data-id for realtime refresh bookkeeping and link to detail pages.
|
||||
expect(html).toContain('data-id');
|
||||
// The timeline div must be present inside cards
|
||||
expect(html).toContain('class="timeline"');
|
||||
// The activity fetch must call the /activity endpoint
|
||||
expect(html).toContain('/activity');
|
||||
// The expansion toggle CSS class must exist
|
||||
expect(html).toContain('expanded');
|
||||
// Token and duration badges must be defined
|
||||
expect(html).toContain('tl-tokens');
|
||||
expect(html).toContain('tl-dur');
|
||||
expect(html).toContain('msToHuman');
|
||||
expect(html).toContain('href="/tasks/');
|
||||
expect(html).not.toContain('class="timeline"');
|
||||
// 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'");
|
||||
});
|
||||
|
||||
it('task detail HTML shows activity metadata and linked decisions with reasoning', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Detail transparency', role: 'implementer' } });
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/decisions',
|
||||
payload: {
|
||||
title: 'Keep cards slim',
|
||||
context: 'Transparency belongs on the task detail page.',
|
||||
decision: 'Task cards link to detail pages instead of expanding inline.',
|
||||
},
|
||||
});
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/handoffs',
|
||||
payload: {
|
||||
fromRole: 'architect',
|
||||
toRole: 'implementer',
|
||||
taskId: 'TSK-0001',
|
||||
summary: 'Implement detail view',
|
||||
decisions: ['DEC-0001'],
|
||||
},
|
||||
});
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/tasks/TSK-0001',
|
||||
payload: { status: 'done', doneTokens: 3210, doneDuration: 65000, doneBy: 'codex' },
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001', headers: { accept: 'text/html' } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/html');
|
||||
expect(res.payload).toContain('Decisions & Reasoning');
|
||||
expect(res.payload).toContain('Keep cards slim');
|
||||
expect(res.payload).toContain('Transparency belongs on the task detail page.');
|
||||
expect(res.payload).toContain('3210 tok');
|
||||
expect(res.payload).toContain('1m');
|
||||
});
|
||||
|
||||
it('serves the activity page with recent activity and done archive', async () => {
|
||||
|
||||
@ -261,6 +261,24 @@ describe('SSE stream e2e', () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('sends an immediate keepalive comment when /events connects', async () => {
|
||||
const controller = new AbortController();
|
||||
const res = await fetch(`${server.url}/events`, { signal: controller.signal });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
||||
expect(res.body).toBeTruthy();
|
||||
|
||||
const reader = res.body!.getReader();
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('SSE: no keepalive frame within 1 s')), 1000);
|
||||
});
|
||||
const chunk = await Promise.race([reader.read(), timeout]);
|
||||
controller.abort();
|
||||
|
||||
expect(chunk.done).toBe(false);
|
||||
expect(new TextDecoder().decode(chunk.value)).toContain(': connected\n\n');
|
||||
}, 3000);
|
||||
|
||||
it('delivers task/created to a connected SSE subscriber', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
|
||||
@ -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');
|
||||
expect(html).toContain('claimed');
|
||||
// The busy agent's card is tagged for the live client sync and rendered active.
|
||||
expect(html).toContain('data-agent="codex"');
|
||||
expect(html).toContain('agent-card active');
|
||||
expect(html).toContain('active');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user