feat: implement spawn1337 autonomous agent protocol

Add full lifecycle management for spawning autonomous Claude sessions as
screen-based agents. Agents communicate via filesystem message bus, signal
completion via RalphTracker <promise> mechanism, and enforce resource budgets
(tokens, cost, timeout, depth limits).

New files:
- spawn-types.ts: Types, YAML parser, factory functions, serialization
- spawn-detector.ts: Terminal pattern detection for spawn1337 tags
- spawn-orchestrator.ts: Agent lifecycle (spawn, monitor, queue, cleanup)
- spawn-claude-md.ts: CLAUDE.md generator for agent sessions

Modified:
- session.ts: SpawnDetector integration, parent/child tracking
- server.ts: Orchestrator wiring, 11 API endpoints, SSE events
- types.ts: Re-exports, SessionState additions

Tests: 80 new tests across 3 test files (all passing)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-23 10:20:37 +01:00
co-authored by Claude Opus 4.5
parent 33a8febf30
commit 1705b7a67d
11 changed files with 3591 additions and 2 deletions
+206
View File
@@ -19,6 +19,8 @@ import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
import { EventEmitter } from 'node:events';
import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem } from '../session.js';
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
import { SpawnOrchestrator, type SessionCreator } from '../spawn-orchestrator.js';
import type { SpawnOrchestratorConfig } from '../spawn-types.js';
import { ScreenManager } from '../screen-manager.js';
import { getStore } from '../state-store.js';
import { generateClaudeMd } from '../templates/claude-md.js';
@@ -154,6 +156,8 @@ export class WebServer extends EventEmitter {
private sseHealthCheckTimer: NodeJS.Timeout | null = null;
// Flag to prevent new timers during shutdown
private _isStopping: boolean = false;
// Spawn1337 agent orchestrator
private spawnOrchestrator: SpawnOrchestrator;
constructor(port: number = 3000) {
super();
@@ -174,6 +178,10 @@ export class WebServer extends EventEmitter {
this.screenManager.on('statsUpdated', (screens) => {
this.broadcast('screen:statsUpdated', screens);
});
// Initialize spawn orchestrator
this.spawnOrchestrator = new SpawnOrchestrator();
this.setupSpawnOrchestratorListeners();
}
private async setupRoutes(): Promise<void> {
@@ -1278,6 +1286,93 @@ export class WebServer extends EventEmitter {
this.app.get('/api/system/stats', async () => {
return this.getSystemStats();
});
// ========== Spawn1337 Agent Protocol Endpoints ==========
this.app.get('/api/spawn/agents', async () => {
return { success: true, data: this.spawnOrchestrator.getAllAgentStatuses() };
});
this.app.get('/api/spawn/agents/:agentId', async (req) => {
const { agentId } = req.params as { agentId: string };
const status = this.spawnOrchestrator.getAgentStatus(agentId);
if (!status) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Agent ${agentId} not found`);
}
return { success: true, data: status };
});
this.app.get('/api/spawn/agents/:agentId/result', async (req) => {
const { agentId } = req.params as { agentId: string };
const result = this.spawnOrchestrator.readAgentResult(agentId);
if (!result) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, `No result found for agent ${agentId}`);
}
return { success: true, data: result };
});
this.app.get('/api/spawn/agents/:agentId/progress', async (req) => {
const { agentId } = req.params as { agentId: string };
const progress = this.spawnOrchestrator.readAgentProgress(agentId);
return { success: true, data: progress };
});
this.app.get('/api/spawn/agents/:agentId/messages', async (req) => {
const { agentId } = req.params as { agentId: string };
const messages = this.spawnOrchestrator.readAgentMessages(agentId);
return { success: true, data: messages };
});
this.app.post('/api/spawn/agents/:agentId/message', async (req) => {
const { agentId } = req.params as { agentId: string };
const { content } = req.body as { content: string };
if (!content) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Message content is required');
}
await this.spawnOrchestrator.sendMessageToAgent(agentId, content);
return { success: true };
});
this.app.post('/api/spawn/agents/:agentId/cancel', async (req) => {
const { agentId } = req.params as { agentId: string };
const { reason } = (req.body as { reason?: string }) || {};
await this.spawnOrchestrator.cancelAgent(agentId, reason || 'Cancelled via API');
return { success: true };
});
this.app.delete('/api/spawn/agents/:agentId', async (req) => {
const { agentId } = req.params as { agentId: string };
await this.spawnOrchestrator.cancelAgent(agentId, 'Force killed via API');
return { success: true };
});
this.app.get('/api/spawn/status', async () => {
return { success: true, data: this.spawnOrchestrator.getState() };
});
this.app.put('/api/spawn/config', async (req) => {
const config = req.body as Partial<SpawnOrchestratorConfig>;
this.spawnOrchestrator.updateConfig(config);
return { success: true, data: this.spawnOrchestrator.config };
});
this.app.post('/api/spawn/trigger', async (req) => {
const { taskContent, parentSessionId, parentWorkingDir } = req.body as {
taskContent: string;
parentSessionId: string;
parentWorkingDir?: string;
};
if (!taskContent || !parentSessionId) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'taskContent and parentSessionId are required');
}
const session = this.sessions.get(parentSessionId);
const workingDir = parentWorkingDir || session?.workingDir || process.cwd();
const agentId = await this.spawnOrchestrator.triggerSpawn(taskContent, parentSessionId, workingDir);
if (!agentId) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse task spec');
}
return { success: true, data: { agentId } };
});
}
/** Persists full session state including respawn config to state.json */
@@ -1514,6 +1609,35 @@ export class WebServer extends EventEmitter {
session.on('ralphCompletionDetected', (phrase: string) => {
this.broadcast('session:ralphCompletionDetected', { sessionId: session.id, phrase });
});
// Spawn1337 protocol events
session.on('spawnRequested', (filePath: string) => {
this.spawnOrchestrator.handleSpawnRequest(
filePath,
session.id,
session.workingDir,
session.parentAgentId ? 1 : 0 // Simple depth tracking
).catch(err => {
console.error(`[Server] Spawn request failed for session ${session.id}:`, getErrorMessage(err));
});
});
session.on('spawnStatusRequested', (agentId: string) => {
const status = this.spawnOrchestrator.getAgentStatus(agentId);
this.broadcast('spawn:statusResponse', { sessionId: session.id, agentId, status });
});
session.on('spawnCancelRequested', (agentId: string) => {
this.spawnOrchestrator.cancelAgent(agentId, 'Cancelled by parent session').catch(err => {
console.error(`[Server] Spawn cancel failed:`, getErrorMessage(err));
});
});
session.on('spawnMessageToChild', (agentId: string, content: string) => {
this.spawnOrchestrator.sendMessageToAgent(agentId, content).catch(err => {
console.error(`[Server] Spawn message failed:`, getErrorMessage(err));
});
});
}
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
@@ -1554,6 +1678,84 @@ export class WebServer extends EventEmitter {
});
}
private setupSpawnOrchestratorListeners(): void {
const sessionCreator: SessionCreator = {
createAgentSession: async (workingDir: string, name: string) => {
const session = new Session({
workingDir,
screenManager: this.screenManager,
useScreen: true,
mode: 'claude',
name: `spawn:${name}`,
});
this.sessions.set(session.id, session);
this.setupSessionListeners(session);
session.parentAgentId = name;
await session.startInteractive();
this.broadcast('session:created', session.toDetailedState());
this.broadcast('session:interactive', { id: session.id });
this.persistSessionState(session);
// Configure ralph tracker for completion detection
session.ralphTracker.enable();
return { sessionId: session.id };
},
writeToSession: (sessionId: string, data: string) => {
const session = this.sessions.get(sessionId);
if (session) {
session.writeViaScreen(data);
}
},
getSessionTokens: (sessionId: string) => {
const session = this.sessions.get(sessionId);
return session ? session.totalTokens : 0;
},
getSessionCost: (sessionId: string) => {
const session = this.sessions.get(sessionId);
return session ? session.totalCost : 0;
},
stopSession: async (sessionId: string) => {
const session = this.sessions.get(sessionId);
if (session) {
await session.stop();
this.sessions.delete(sessionId);
this.broadcast('session:deleted', { id: sessionId });
this.persistSessionState(session);
}
},
onSessionCompletion: (sessionId: string, handler: (phrase: string) => void) => {
const session = this.sessions.get(sessionId);
if (session) {
session.on('ralphCompletionDetected', handler);
}
},
removeSessionCompletionHandler: (sessionId: string, handler: (phrase: string) => void) => {
const session = this.sessions.get(sessionId);
if (session) {
session.off('ralphCompletionDetected', handler);
}
},
};
this.spawnOrchestrator.setSessionCreator(sessionCreator);
// Forward orchestrator events as SSE broadcasts
this.spawnOrchestrator.on('queued', (data) => this.broadcast('spawn:queued', data));
this.spawnOrchestrator.on('initializing', (data) => this.broadcast('spawn:initializing', data));
this.spawnOrchestrator.on('started', (data) => this.broadcast('spawn:started', data));
this.spawnOrchestrator.on('progress', (data) => this.broadcast('spawn:progress', data));
this.spawnOrchestrator.on('message', (data) => this.broadcast('spawn:message', data));
this.spawnOrchestrator.on('completed', (data) => this.broadcast('spawn:completed', data));
this.spawnOrchestrator.on('failed', (data) => this.broadcast('spawn:failed', data));
this.spawnOrchestrator.on('timeout', (data) => this.broadcast('spawn:timeout', data));
this.spawnOrchestrator.on('cancelled', (data) => this.broadcast('spawn:cancelled', data));
this.spawnOrchestrator.on('budgetWarning', (data) => this.broadcast('spawn:budgetWarning', data));
this.spawnOrchestrator.on('stateUpdate', (data) => this.broadcast('spawn:stateUpdate', data));
}
private setupTimedRespawn(sessionId: string, durationMinutes: number): void {
// Clear existing timer if any
const existing = this.respawnTimers.get(sessionId);
@@ -2156,6 +2358,10 @@ export class WebServer extends EventEmitter {
}
this.respawnControllers.clear();
// Stop spawn orchestrator and all agents
await this.spawnOrchestrator.stopAll();
this.spawnOrchestrator.removeAllListeners();
// Stop all scheduled runs first (they have their own session cleanup)
for (const [id] of this.scheduledRuns) {
await this.stopScheduledRun(id);