Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-relay",
"version": "1.0.20",
"version": "1.0.21",
"description": "Real-time agent-to-agent communication system",
"type": "module",
"main": "dist/index.js",
Expand Down
135 changes: 135 additions & 0 deletions src/bridge/teams-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Teams Configuration
* Handles loading and parsing teams.json for auto-spawn and agent validation.
*
* teams.json can be placed in:
* - Project root: ./teams.json
* - Agent-relay dir: ./.agent-relay/teams.json
*/

import fs from 'node:fs';
import path from 'node:path';

/** Agent definition in teams.json */
export interface TeamAgentConfig {
/** Agent name (used for spawn and validation) */
name: string;
/** CLI command to use (e.g., 'claude', 'claude:opus', 'codex') */
cli: string;
/** Agent role (e.g., 'coordinator', 'developer', 'reviewer') */
role?: string;
/** Initial task/prompt to inject when spawning */
task?: string;
}

/** teams.json file structure */
export interface TeamsConfig {
/** Team name (for identification) */
team: string;
/** Agents defined in this team */
agents: TeamAgentConfig[];
/** If true, agent-relay up will auto-spawn all agents */
autoSpawn?: boolean;
}

/**
* Possible locations for teams.json (in order of precedence)
*/
function getTeamsConfigPaths(projectRoot: string): string[] {
return [
path.join(projectRoot, '.agent-relay', 'teams.json'),
path.join(projectRoot, 'teams.json'),
];
}

/**
* Load teams.json from project root or .agent-relay directory
* Returns null if no config found
*/
export function loadTeamsConfig(projectRoot: string): TeamsConfig | null {
const configPaths = getTeamsConfigPaths(projectRoot);

for (const configPath of configPaths) {
if (fs.existsSync(configPath)) {
try {
const content = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(content) as TeamsConfig;

// Validate required fields
if (!config.team || typeof config.team !== 'string') {
console.error(`[teams-config] Invalid teams.json at ${configPath}: missing or invalid 'team' field`);
continue;
}

if (!Array.isArray(config.agents)) {
console.error(`[teams-config] Invalid teams.json at ${configPath}: 'agents' must be an array`);
continue;
}

// Validate agents
const validAgents: TeamAgentConfig[] = [];
for (const agent of config.agents) {
if (!agent.name || typeof agent.name !== 'string') {
console.warn(`[teams-config] Skipping agent with missing name in ${configPath}`);
continue;
}
if (!agent.cli || typeof agent.cli !== 'string') {
console.warn(`[teams-config] Agent '${agent.name}' missing 'cli' field, defaulting to 'claude'`);
agent.cli = 'claude';
}
validAgents.push(agent);
}

console.log(`[teams-config] Loaded team '${config.team}' from ${configPath} (${validAgents.length} agents)`);

return {
team: config.team,
agents: validAgents,
autoSpawn: config.autoSpawn ?? false,
};
} catch (err) {
console.error(`[teams-config] Failed to parse ${configPath}:`, err);
}
}
}

return null;
}

/**
* Check if an agent name is valid according to teams.json
* Returns true if no teams.json exists (permissive mode)
*/
export function isValidAgentName(projectRoot: string, agentName: string): boolean {
const config = loadTeamsConfig(projectRoot);

// No config = permissive mode
if (!config) {
return true;
}

return config.agents.some(a => a.name === agentName);
}

/**
* Get agent config by name from teams.json
*/
export function getAgentConfig(projectRoot: string, agentName: string): TeamAgentConfig | null {
const config = loadTeamsConfig(projectRoot);
if (!config) return null;

return config.agents.find(a => a.name === agentName) ?? null;
}

/**
* Get teams.json path that would be used (for error messages)
*/
export function getTeamsConfigPath(projectRoot: string): string | null {
const configPaths = getTeamsConfigPaths(projectRoot);
for (const configPath of configPaths) {
if (fs.existsSync(configPath)) {
return configPath;
}
}
return null;
}
61 changes: 59 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,12 @@ program
.description('Start daemon + dashboard')
.option('--no-dashboard', 'Disable web dashboard')
.option('--port <port>', 'Dashboard port', DEFAULT_DASHBOARD_PORT)
.option('--spawn', 'Force spawn all agents from teams.json')
.option('--no-spawn', 'Do not auto-spawn agents (just start daemon)')
.action(async (options) => {
const { ensureProjectDir } = await import('../utils/project-namespace.js');
const { loadTeamsConfig } = await import('../bridge/teams-config.js');
const { AgentSpawner } = await import('../bridge/spawner.js');

const paths = ensureProjectDir();
const socketPath = paths.socketPath;
Expand All @@ -160,20 +164,35 @@ program
console.log(`Project: ${paths.projectRoot}`);
console.log(`Socket: ${socketPath}`);

// Load teams.json if present
const teamsConfig = loadTeamsConfig(paths.projectRoot);
if (teamsConfig) {
console.log(`Team: ${teamsConfig.team} (${teamsConfig.agents.length} agents defined)`);
}

const daemon = new Daemon({
socketPath,
pidFilePath,
storagePath: dbPath,
teamDir: paths.teamDir,
});

// Create spawner for auto-spawn (will be initialized after dashboard starts)
let spawner: InstanceType<typeof AgentSpawner> | null = null;

process.on('SIGINT', async () => {
console.log('\nStopping...');
if (spawner) {
await spawner.releaseAll();
}
await daemon.stop();
process.exit(0);
});

process.on('SIGTERM', async () => {
if (spawner) {
await spawner.releaseAll();
}
await daemon.stop();
process.exit(0);
});
Expand All @@ -182,19 +201,57 @@ program
await daemon.start();
console.log('Daemon started.');

let dashboardPort: number | undefined;

// Dashboard starts by default (use --no-dashboard to disable)
if (options.dashboard !== false) {
const port = parseInt(options.port, 10);
const { startDashboard } = await import('../dashboard-server/server.js');
const actualPort = await startDashboard({
dashboardPort = await startDashboard({
port,
dataDir: paths.dataDir,
teamDir: paths.teamDir,
dbPath,
enableSpawner: true,
projectRoot: paths.projectRoot,
});
console.log(`Dashboard: http://localhost:${actualPort}`);
console.log(`Dashboard: http://localhost:${dashboardPort}`);
}

// Determine if we should auto-spawn agents
// --spawn: force spawn
// --no-spawn: never spawn
// Neither: check teamsConfig.autoSpawn
const shouldSpawn = options.spawn === true
? true
: options.spawn === false
? false
: teamsConfig?.autoSpawn ?? false;

if (shouldSpawn && teamsConfig && teamsConfig.agents.length > 0) {
console.log('');
console.log('Auto-spawning agents from teams.json...');

spawner = new AgentSpawner(paths.projectRoot, undefined, dashboardPort);

for (const agent of teamsConfig.agents) {
console.log(` Spawning ${agent.name} (${agent.cli})...`);
const result = await spawner.spawn({
name: agent.name,
cli: agent.cli,
task: agent.task ?? '',
team: teamsConfig.team,
});

if (result.success) {
console.log(` ✓ ${agent.name} started [pid: ${result.pid}]`);
} else {
console.error(` ✗ ${agent.name} failed: ${result.error}`);
}
}
console.log('');
} else if (options.spawn === true && !teamsConfig) {
console.warn('Warning: --spawn specified but no teams.json found');
}

console.log('Press Ctrl+C to stop.');
Expand Down
Loading