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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-relay",
"version": "1.0.12",
"version": "1.0.13",
"description": "Real-time agent-to-agent communication system",
"type": "module",
"main": "dist/index.js",
Expand All @@ -24,7 +24,7 @@
"postinstall": "npm rebuild better-sqlite3 && node scripts/postinstall.js",
"build": "npm run clean && tsc && npm run build:dashboard",
"build:dashboard": "cd src/dashboard && npm run build",
"postbuild": "chmod +x dist/cli/index.js",
"postbuild": "chmod +x dist/cli/index.js && mkdir -p dist/dashboard && cp -r src/dashboard/out dist/dashboard/",
"dev": "tsc -w",
"dev:local": "npm run build && npm link && echo '✓ agent-relay linked globally'",
"dev:unlink": "npm unlink -g agent-relay && echo '✓ agent-relay unlinked'",
Expand Down
24 changes: 19 additions & 5 deletions src/bridge/spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,29 @@ export class AgentSpawner {
private socketPath?: string;
private logsDir: string;
private workersPath: string;
private dashboardPort?: number;

constructor(projectRoot: string, _tmuxSession?: string) {
constructor(projectRoot: string, _tmuxSession?: string, dashboardPort?: number) {
const paths = getProjectPaths(projectRoot);
this.projectRoot = paths.projectRoot;
this.agentsPath = path.join(paths.teamDir, 'agents.json');
this.socketPath = paths.socketPath;
this.logsDir = path.join(paths.teamDir, 'worker-logs');
this.workersPath = path.join(paths.teamDir, 'workers.json');
this.dashboardPort = dashboardPort;

// Ensure logs directory exists
fs.mkdirSync(this.logsDir, { recursive: true });
}

/**
* Set the dashboard port (for nested spawn API calls).
* Called after the dashboard server starts and we know the actual port.
*/
setDashboardPort(port: number): void {
this.dashboardPort = port;
}

/**
* Spawn a new worker agent using node-pty
*/
Expand Down Expand Up @@ -84,15 +94,19 @@ export class AgentSpawner {
if (debug) console.log(`[spawner:debug] Spawning ${name} with: ${command} ${args.join(' ')}`);

// Create PtyWrapper config
// Use dashboardPort for nested spawns (API-based, works in non-TTY contexts)
// Fall back to callbacks only if no dashboardPort is set
const ptyConfig: PtyWrapperConfig = {
name,
command,
args,
socketPath: this.socketPath,
cwd: this.projectRoot,
logsDir: this.logsDir,
onSpawn: async (workerName, workerCli, workerTask) => {
// Handle nested spawn requests
dashboardPort: this.dashboardPort,
// Only use callbacks if dashboardPort is not set (for backwards compatibility)
onSpawn: this.dashboardPort ? undefined : async (workerName, workerCli, workerTask) => {
// Handle nested spawn requests (legacy path, may fail in non-TTY)
if (debug) console.log(`[spawner:debug] Nested spawn: ${workerName}`);
await this.spawn({
name: workerName,
Expand All @@ -101,8 +115,8 @@ export class AgentSpawner {
requestedBy: name,
});
},
onRelease: async (workerName) => {
// Handle release requests from workers
onRelease: this.dashboardPort ? undefined : async (workerName) => {
// Handle release requests from workers (legacy path)
if (debug) console.log(`[spawner:debug] Release request: ${workerName}`);
await this.release(workerName);
},
Expand Down
6 changes: 6 additions & 0 deletions src/dashboard-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,12 @@ export async function startDashboard(
server.listen(availablePort, () => {
console.log(`Dashboard running at http://localhost:${availablePort}`);
console.log(`Monitoring: ${dataDir}`);

// Set the dashboard port on spawner so spawned agents can use the API for nested spawns
if (spawner) {
spawner.setDashboardPort(availablePort);
}

resolve(availablePort);
});

Expand Down
86 changes: 74 additions & 12 deletions src/wrapper/pty-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ export interface PtyWrapperConfig {
relayPrefix?: string;
/** Directory to write log files (optional) */
logsDir?: string;
/** Callback for spawn commands */
/** Dashboard port for spawn/release API calls (enables nested spawning from spawned agents) */
dashboardPort?: number;
/** Callback for spawn commands (fallback if dashboardPort not set) */
onSpawn?: (name: string, cli: string, task: string) => Promise<void>;
/** Callback for release commands */
/** Callback for release commands (fallback if dashboardPort not set) */
onRelease?: (name: string) => Promise<void>;
/** Callback when agent exits */
onExit?: (code: number) => void;
Expand Down Expand Up @@ -321,10 +323,14 @@ export class PtyWrapper extends EventEmitter {

/**
* Parse spawn/release commands from output
* Uses string-based parsing for robustness with PTY output
* Uses string-based parsing for robustness with PTY output.
* Delegates to dashboard API if dashboardPort is set (for nested spawns).
*/
private parseSpawnReleaseCommands(content: string): void {
if (!this.config.onSpawn && !this.config.onRelease) return;
// Need either API port or callbacks to handle spawn/release
const canSpawn = this.config.dashboardPort || this.config.onSpawn;
const canRelease = this.config.dashboardPort || this.config.onRelease;
if (!canSpawn && !canRelease) return;

const lines = content.split('\n');
const spawnPrefix = '->relay:spawn';
Expand All @@ -333,7 +339,7 @@ export class PtyWrapper extends EventEmitter {
for (const line of lines) {
// Check for spawn command
const spawnIdx = line.indexOf(spawnPrefix);
if (spawnIdx !== -1 && this.config.onSpawn) {
if (spawnIdx !== -1 && canSpawn) {
const afterSpawn = line.substring(spawnIdx + spawnPrefix.length).trim();
// Parse: WorkerName cli "task" or WorkerName cli 'task'
const parts = afterSpawn.split(/\s+/);
Expand All @@ -350,9 +356,7 @@ export class PtyWrapper extends EventEmitter {
const spawnKey = `${name}:${cli}:${task}`;
if (!this.processedSpawnCommands.has(spawnKey)) {
this.processedSpawnCommands.add(spawnKey);
this.config.onSpawn(name, cli, task).catch(err => {
console.error(`[pty:${this.config.name}] Spawn failed: ${err.message}`);
});
this.executeSpawn(name, cli, task);
}
}
}
Expand All @@ -361,20 +365,78 @@ export class PtyWrapper extends EventEmitter {

// Check for release command
const releaseIdx = line.indexOf(releasePrefix);
if (releaseIdx !== -1 && this.config.onRelease) {
if (releaseIdx !== -1 && canRelease) {
const afterRelease = line.substring(releaseIdx + releasePrefix.length).trim();
const name = afterRelease.split(/\s+/)[0];

if (name && !this.processedReleaseCommands.has(name)) {
this.processedReleaseCommands.add(name);
this.config.onRelease(name).catch(err => {
console.error(`[pty:${this.config.name}] Release failed: ${err.message}`);
});
this.executeRelease(name);
}
}
}
}

/**
* Execute spawn via API or callback
*/
private async executeSpawn(name: string, cli: string, task: string): Promise<void> {
if (this.config.dashboardPort) {
// Use dashboard API for spawning (works from spawned agents)
try {
const response = await fetch(`http://localhost:${this.config.dashboardPort}/api/spawn`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, cli, task }),
});
const result = await response.json() as { success: boolean; error?: string };
if (result.success) {
console.log(`[pty:${this.config.name}] Spawned ${name} via API`);
} else {
console.error(`[pty:${this.config.name}] Spawn failed: ${result.error}`);
}
} catch (err: any) {
console.error(`[pty:${this.config.name}] Spawn API call failed: ${err.message}`);
}
} else if (this.config.onSpawn) {
// Fall back to callback
try {
await this.config.onSpawn(name, cli, task);
} catch (err: any) {
console.error(`[pty:${this.config.name}] Spawn failed: ${err.message}`);
}
}
}

/**
* Execute release via API or callback
*/
private async executeRelease(name: string): Promise<void> {
if (this.config.dashboardPort) {
// Use dashboard API for releasing
try {
const response = await fetch(`http://localhost:${this.config.dashboardPort}/api/spawned/${name}`, {
method: 'DELETE',
});
const result = await response.json() as { success: boolean; error?: string };
if (result.success) {
console.log(`[pty:${this.config.name}] Released ${name} via API`);
} else {
console.error(`[pty:${this.config.name}] Release failed: ${result.error}`);
}
} catch (err: any) {
console.error(`[pty:${this.config.name}] Release API call failed: ${err.message}`);
}
} else if (this.config.onRelease) {
// Fall back to callback
try {
await this.config.onRelease(name);
} catch (err: any) {
console.error(`[pty:${this.config.name}] Release failed: ${err.message}`);
}
}
}

/**
* Handle incoming message from relay
*/
Expand Down