From c8f0d0b9ea87b6e8b8d8ec56f7a359efd5fbdc33 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 28 Dec 2025 22:51:42 -0500 Subject: [PATCH 1/3] Fix nested spawn from spawned agents via API delegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spawned agents (running via PtyWrapper) couldn't spawn other agents because node-pty fails without a TTY. Now spawned agents delegate spawn/release requests to the dashboard API instead of trying to spawn locally. Changes: - PtyWrapper: Added dashboardPort config option and executeSpawn/executeRelease methods that call the dashboard API when the port is set - AgentSpawner: Accept dashboardPort in constructor and pass to PtyWrapper, added setDashboardPort() method for late binding - Dashboard server: Set dashboard port on spawner after server starts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/bridge/spawner.ts | 24 ++++++++-- src/dashboard-server/server.ts | 6 +++ src/wrapper/pty-wrapper.ts | 86 +++++++++++++++++++++++++++++----- 3 files changed, 99 insertions(+), 17 deletions(-) diff --git a/src/bridge/spawner.ts b/src/bridge/spawner.ts index f6e864d66..541f06778 100644 --- a/src/bridge/spawner.ts +++ b/src/bridge/spawner.ts @@ -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 */ @@ -84,6 +94,8 @@ 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, @@ -91,8 +103,10 @@ export class AgentSpawner { 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, @@ -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); }, diff --git a/src/dashboard-server/server.ts b/src/dashboard-server/server.ts index 483f5693a..db27ebefb 100644 --- a/src/dashboard-server/server.ts +++ b/src/dashboard-server/server.ts @@ -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); }); diff --git a/src/wrapper/pty-wrapper.ts b/src/wrapper/pty-wrapper.ts index b47947829..7a053672a 100644 --- a/src/wrapper/pty-wrapper.ts +++ b/src/wrapper/pty-wrapper.ts @@ -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; - /** Callback for release commands */ + /** Callback for release commands (fallback if dashboardPort not set) */ onRelease?: (name: string) => Promise; /** Callback when agent exits */ onExit?: (code: number) => void; @@ -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'; @@ -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+/); @@ -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); } } } @@ -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 { + 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 { + 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 */ From c9b138ecb380f70ec856b37e31cfe596dd6bd613 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 28 Dec 2025 22:54:38 -0500 Subject: [PATCH 2/3] Fix dashboard 404 in published package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard files weren't being copied to dist/ during build. Added postbuild step to copy src/dashboard/out to dist/dashboard/out. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ac58d6c01..f726b7e7c 100644 --- a/package.json +++ b/package.json @@ -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'", From 49378649a65c597c3fee295f3f68a6572c81bec6 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 28 Dec 2025 22:55:22 -0500 Subject: [PATCH 3/3] bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f726b7e7c..f104094bb 100644 --- a/package.json +++ b/package.json @@ -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",