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
+78
View File
@@ -21,6 +21,7 @@ import * as pty from 'node-pty';
import { SessionState, SessionStatus, SessionConfig, ScreenSession, RalphTrackerState, RalphTodoItem } from './types.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
import { RalphTracker } from './ralph-tracker.js';
import { SpawnDetector } from './spawn-detector.js';
import { ScreenManager } from './screen-manager.js';
export type { BackgroundTask } from './task-tracker.js';
@@ -225,6 +226,14 @@ export interface SessionEvents {
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
/** Ralph completion phrase detected */
ralphCompletionDetected: (phrase: string) => void;
/** Spawn1337 agent spawn requested */
spawnRequested: (filePath: string, rawLine: string) => void;
/** Spawn1337 agent status query */
spawnStatusRequested: (agentId: string) => void;
/** Spawn1337 agent cancel request */
spawnCancelRequested: (agentId: string) => void;
/** Spawn1337 message to child agent */
spawnMessageToChild: (agentId: string, content: string) => void;
}
/**
@@ -325,6 +334,13 @@ export class Session extends EventEmitter {
// Ralph tracking (Ralph Wiggum loops and todo lists inside Claude Code)
private _ralphTracker: RalphTracker;
// Spawn1337 detection (agent spawning protocol)
private _spawnDetector: SpawnDetector;
// Agent tree tracking
private _parentAgentId: string | null = null;
private _childAgentIds: string[] = [];
// Store handler references for cleanup (prevents memory leaks)
private _taskTrackerHandlers: {
taskCreated: (task: BackgroundTask) => void;
@@ -339,6 +355,13 @@ export class Session extends EventEmitter {
completionDetected: (phrase: string) => void;
} | null = null;
private _spawnHandlers: {
spawnRequested: (filePath: string, rawLine: string) => void;
statusRequested: (agentId: string) => void;
cancelRequested: (agentId: string) => void;
messageToChild: (agentId: string, content: string) => void;
} | null = null;
constructor(config: Partial<SessionConfig> & {
workingDir: string;
mode?: SessionMode;
@@ -381,6 +404,19 @@ export class Session extends EventEmitter {
this._ralphTracker.on('loopUpdate', this._ralphHandlers.loopUpdate);
this._ralphTracker.on('todoUpdate', this._ralphHandlers.todoUpdate);
this._ralphTracker.on('completionDetected', this._ralphHandlers.completionDetected);
// Initialize Spawn detector and forward events (store handlers for cleanup)
this._spawnDetector = new SpawnDetector();
this._spawnHandlers = {
spawnRequested: (filePath, rawLine) => this.emit('spawnRequested', filePath, rawLine),
statusRequested: (agentId) => this.emit('spawnStatusRequested', agentId),
cancelRequested: (agentId) => this.emit('spawnCancelRequested', agentId),
messageToChild: (agentId, content) => this.emit('spawnMessageToChild', agentId, content),
};
this._spawnDetector.on('spawnRequested', this._spawnHandlers.spawnRequested);
this._spawnDetector.on('statusRequested', this._spawnHandlers.statusRequested);
this._spawnDetector.on('cancelRequested', this._spawnHandlers.cancelRequested);
this._spawnDetector.on('messageToChild', this._spawnHandlers.messageToChild);
}
get status(): SessionStatus {
@@ -468,6 +504,34 @@ export class Session extends EventEmitter {
return this._ralphTracker.getTodoStats();
}
// Spawn1337 tracking getters
get spawnDetector(): SpawnDetector {
return this._spawnDetector;
}
get parentAgentId(): string | null {
return this._parentAgentId;
}
set parentAgentId(value: string | null) {
this._parentAgentId = value;
}
get childAgentIds(): string[] {
return [...this._childAgentIds];
}
addChildAgentId(agentId: string): void {
if (!this._childAgentIds.includes(agentId)) {
this._childAgentIds.push(agentId);
}
}
removeChildAgentId(agentId: string): void {
const idx = this._childAgentIds.indexOf(agentId);
if (idx >= 0) this._childAgentIds.splice(idx, 1);
}
// Token tracking getters and setters
get totalTokens(): number {
return this._totalInputTokens + this._totalOutputTokens;
@@ -559,6 +623,8 @@ export class Session extends EventEmitter {
outputTokens: this._totalOutputTokens,
ralphEnabled: this._ralphTracker.enabled,
ralphCompletionPhrase: this._ralphTracker.loopState.completionPhrase || undefined,
parentAgentId: this._parentAgentId || undefined,
childAgentIds: this._childAgentIds.length > 0 ? this._childAgentIds : undefined,
};
}
@@ -743,6 +809,9 @@ export class Session extends EventEmitter {
// Forward to Ralph tracker to detect Ralph loops and todos
this._ralphTracker.processTerminalData(data);
// Forward to Spawn detector to detect spawn1337 protocol tags
this._spawnDetector.processTerminalData(data);
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
this.parseTokensFromStatusLine(data);
@@ -1390,6 +1459,15 @@ export class Session extends EventEmitter {
this._ralphTracker.off('completionDetected', this._ralphHandlers.completionDetected);
this._ralphHandlers = null;
}
// Remove SpawnDetector handlers
if (this._spawnHandlers) {
this._spawnDetector.off('spawnRequested', this._spawnHandlers.spawnRequested);
this._spawnDetector.off('statusRequested', this._spawnHandlers.statusRequested);
this._spawnDetector.off('cancelRequested', this._spawnHandlers.cancelRequested);
this._spawnDetector.off('messageToChild', this._spawnHandlers.messageToChild);
this._spawnHandlers = null;
}
}
/**
+154
View File
@@ -0,0 +1,154 @@
/**
* @fileoverview Agent CLAUDE.md Generator for spawn1337 protocol.
*
* Generates a comprehensive CLAUDE.md for each spawned agent that tells it:
* - What its task is
* - How to communicate progress
* - How to signal completion
* - What constraints it has
* - How to read/write messages
*
* @module spawn-claude-md
*/
import type { SpawnTask } from './spawn-types.js';
/**
* Generate a CLAUDE.md file for a spawned agent.
*
* This CLAUDE.md gives the agent full context about:
* - Its identity and task
* - Communication protocol (progress, messages, result)
* - Resource constraints (timeout, tokens, cost)
* - Working directory and available context files
*
* @param task - The full parsed task specification
* @param commsDir - Absolute path to the communication directory
* @param agentWorkingDir - Absolute path to the agent's working directory
* @returns The generated CLAUDE.md content
*/
export function generateAgentClaudeMd(task: SpawnTask, commsDir: string, agentWorkingDir: string): string {
const spec = task.spec;
const constraintLines: string[] = [];
constraintLines.push(`- Timeout: ${spec.timeoutMinutes} minutes`);
if (spec.maxTokens) constraintLines.push(`- Token budget: ${spec.maxTokens.toLocaleString()} tokens`);
if (spec.maxCost) constraintLines.push(`- Cost budget: $${spec.maxCost.toFixed(2)}`);
if (!spec.canModifyParentFiles) {
constraintLines.push('- DO NOT modify files outside your workspace');
} else {
constraintLines.push('- You MAY modify files in the parent project directory');
}
constraintLines.push(`- Output format: ${spec.outputFormat}`);
const contextSection = spec.contextFiles && spec.contextFiles.length > 0
? `\nContext files available in workspace:\n${spec.contextFiles.map(f => `- ${f}`).join('\n')}`
: '';
const progressSection = spec.progressIntervalSeconds > 0
? `### Progress Reporting
Update \`${commsDir}/progress.json\` every ~${spec.progressIntervalSeconds} seconds with your current status:
\`\`\`json
{
"phase": "current phase description",
"percentComplete": 45,
"currentAction": "What you are doing right now",
"subtasks": [
{"description": "Subtask 1", "status": "completed"},
{"description": "Subtask 2", "status": "in_progress"}
],
"filesModified": ["file1.ts", "file2.ts"],
"tokensUsed": 0,
"costSoFar": 0,
"updatedAt": ${Date.now()}
}
\`\`\``
: '### Progress Reporting\n\nProgress reporting is disabled for this task.';
return `# Agent: ${spec.name}
## Your Identity
You are an autonomous agent (ID: \`${spec.agentId}\`) spawned by a parent Claude session.
You are running in your own screen session with full Claude Code capabilities.
Type: ${spec.type} | Priority: ${spec.priority} | Depth: ${task.depth}
## Task
${task.instructions}
## Success Criteria
${spec.successCriteria || 'Complete the task as described above.'}
## Communication Protocol
${progressSection}
### Check for Messages
Periodically check \`${commsDir}/messages/\` for instructions from the parent.
Files are named \`NNN-parent.md\` (from parent) or \`NNN-agent.md\` (from you).
Read any new \`*-parent.md\` files for additional instructions or clarifications.
To send a message back to the parent, create a file like:
\`${commsDir}/messages/002-agent.md\`
### Write Result
When complete, write your final result to \`${commsDir}/result.md\` with YAML frontmatter:
\`\`\`markdown
---
status: completed
summary: "Brief 1-3 sentence summary of what you accomplished"
filesChanged:
- path: relative/path/to/file.ts
action: modified
summary: "What was changed"
---
## Detailed Output
Your full output, analysis, or report here.
\`\`\`
Valid status values: \`completed\`, \`failed\`
### Signal Completion
After writing result.md, output this EXACT phrase to signal you are done:
<promise>${spec.completionPhrase}</promise>
**IMPORTANT**: Only output the completion phrase AFTER you have written result.md.
The completion phrase triggers the orchestrator to read your result and clean up.
## Constraints
${constraintLines.join('\n')}
## Working Directory
Your workspace is: \`${agentWorkingDir}\`
${contextSection}
## Important Notes
- Work autonomously - do not ask for user input
- Focus exclusively on the task described above
- If you encounter errors, document them in result.md with status: failed
- Do not modify this CLAUDE.md file
- Stay within your resource constraints
`;
}
/**
* Build the initial prompt injected into the agent session via writeViaScreen().
* Intentionally brief - all detail is in the CLAUDE.md.
*/
export function buildInitialPrompt(task: SpawnTask): string {
return `Read your CLAUDE.md file for complete task instructions, communication protocol, and constraints. Begin working on the task immediately. Report progress to spawn-comms/progress.json periodically. When complete, write your result to spawn-comms/result.md and then output your completion phrase: <promise>${task.spec.completionPhrase}</promise>`;
}
+292
View File
@@ -0,0 +1,292 @@
/**
* @fileoverview Spawn Detector - Detects spawn1337 tags in terminal output.
*
* Monitors terminal output for spawn protocol patterns:
* - <spawn1337>filename.md</spawn1337> - Agent spawn request
* - <spawn1337-status agentId="..."/> - Status query
* - <spawn1337-cancel agentId="..."/> - Cancel request
* - <spawn1337-message agentId="...">content</spawn1337-message> - Message to child
*
* Same architecture as ralph-tracker.ts: line-buffered, auto-enabling,
* debounced events, pre-compiled patterns.
*
* @module spawn-detector
*/
import { EventEmitter } from 'node:events';
import { SpawnTrackerState, createInitialSpawnTrackerState } from './spawn-types.js';
// ========== Configuration Constants ==========
/** Debounce interval for state event emissions (ms) */
const EVENT_DEBOUNCE_MS = 50;
/** Maximum line buffer size to prevent unbounded growth */
const MAX_LINE_BUFFER_SIZE = 64 * 1024;
// ========== Pre-compiled Regex Patterns ==========
/** Matches spawn request tags: <spawn1337>filename.md</spawn1337> */
const SPAWN_TAG_PATTERN = /<spawn1337>([^<]+)<\/spawn1337>/;
/** Quick check string before running regex */
const SPAWN_QUICK_CHECK = 'spawn1337';
/** Matches status query: <spawn1337-status agentId="..."/> */
const SPAWN_STATUS_PATTERN = /<spawn1337-status\s+agentId="([^"]+)"\s*\/>/;
/** Matches cancel request: <spawn1337-cancel agentId="..."/> */
const SPAWN_CANCEL_PATTERN = /<spawn1337-cancel\s+agentId="([^"]+)"\s*\/>/;
/** Matches message to child: <spawn1337-message agentId="...">content</spawn1337-message> */
const SPAWN_MESSAGE_PATTERN = /<spawn1337-message\s+agentId="([^"]+)">([\s\S]*?)<\/spawn1337-message>/;
/** Removes ANSI escape codes from terminal output */
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
// ========== Event Types ==========
/**
* Events emitted by SpawnDetector
*/
export interface SpawnDetectorEvents {
/** Emitted when a spawn request tag is detected */
spawnRequested: (filePath: string, rawLine: string) => void;
/** Emitted when a status query is detected */
statusRequested: (agentId: string) => void;
/** Emitted when a cancel request is detected */
cancelRequested: (agentId: string) => void;
/** Emitted when a message to child is detected */
messageToChild: (agentId: string, content: string) => void;
/** Emitted when tracker state changes */
stateUpdate: (state: SpawnTrackerState) => void;
}
/**
* SpawnDetector - Parses terminal output to detect spawn1337 protocol tags.
*
* This class monitors Claude Code session output to detect agent spawn requests
* and related communication patterns. It auto-enables when any spawn1337 pattern
* is first detected, reducing overhead for sessions not using the spawn protocol.
*
* ## Pattern Detection
*
* 1. **Spawn Request**: `<spawn1337>path/to/task.md</spawn1337>`
* 2. **Status Query**: `<spawn1337-status agentId="id"/>`
* 3. **Cancel Request**: `<spawn1337-cancel agentId="id"/>`
* 4. **Message**: `<spawn1337-message agentId="id">content</spawn1337-message>`
*
* @extends EventEmitter
*/
export class SpawnDetector extends EventEmitter {
/** Whether the detector is actively monitoring output */
private _enabled: boolean = false;
/** Buffer for incomplete lines from terminal data */
private _lineBuffer: string = '';
/** Current tracker state */
private _state: SpawnTrackerState;
/** Debounce timer for state events */
private _stateUpdateTimer: NodeJS.Timeout | null = null;
/** Flag indicating pending state update emission */
private _stateUpdatePending: boolean = false;
constructor() {
super();
this._state = createInitialSpawnTrackerState();
}
/**
* Whether the detector is enabled and actively monitoring output.
*/
get enabled(): boolean {
return this._enabled;
}
/**
* Get a copy of the current tracker state.
*/
get state(): SpawnTrackerState {
return { ...this._state };
}
/**
* Enable the detector to start monitoring terminal output.
*/
enable(): void {
if (!this._enabled) {
this._enabled = true;
this._state.enabled = true;
this.emitStateUpdateDebounced();
}
}
/**
* Disable the detector.
*/
disable(): void {
if (this._enabled) {
this._enabled = false;
this._state.enabled = false;
this.emitStateUpdateDebounced();
}
}
/**
* Reset all state.
*/
reset(): void {
this.clearDebounceTimers();
this._enabled = false;
this._lineBuffer = '';
this._state = createInitialSpawnTrackerState();
this.emit('stateUpdate', this.state);
}
/**
* Update state from orchestrator data.
* Called by server.ts when orchestrator state changes.
*/
updateState(state: Partial<SpawnTrackerState>): void {
Object.assign(this._state, state);
this.emitStateUpdateDebounced();
}
/**
* Process raw terminal data to detect spawn patterns.
*
* @param data - Raw terminal data (may include ANSI codes)
*/
processTerminalData(data: string): void {
// Remove ANSI escape codes
const cleanData = data.replace(ANSI_ESCAPE_PATTERN, '');
// Buffer data for line-based processing
this._lineBuffer += cleanData;
// Prevent unbounded line buffer growth
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
this._lineBuffer = this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
}
// Quick pre-check: if no spawn patterns in buffer, just drain lines
if (!this._lineBuffer.includes(SPAWN_QUICK_CHECK)) {
const lines = this._lineBuffer.split('\n');
this._lineBuffer = lines.pop() || '';
return;
}
// Auto-enable on first spawn pattern detection
if (!this._enabled) {
this.enable();
}
// Process complete lines
const lines = this._lineBuffer.split('\n');
this._lineBuffer = lines.pop() || '';
for (const line of lines) {
this.processLine(line);
}
// Also check the full chunk for multi-line patterns (message tag can span lines)
this.checkMultiLinePatterns(cleanData);
}
/**
* Process a single line for spawn patterns.
*/
private processLine(line: string): void {
const trimmed = line.trim();
if (!trimmed || !trimmed.includes(SPAWN_QUICK_CHECK)) return;
// Check spawn request: <spawn1337>filename.md</spawn1337>
const spawnMatch = trimmed.match(SPAWN_TAG_PATTERN);
if (spawnMatch) {
const filePath = spawnMatch[1].trim();
this._state.totalSpawned++;
this.emit('spawnRequested', filePath, trimmed);
this.emitStateUpdateDebounced();
return;
}
// Check status query: <spawn1337-status agentId="..."/>
const statusMatch = trimmed.match(SPAWN_STATUS_PATTERN);
if (statusMatch) {
this.emit('statusRequested', statusMatch[1]);
return;
}
// Check cancel request: <spawn1337-cancel agentId="..."/>
const cancelMatch = trimmed.match(SPAWN_CANCEL_PATTERN);
if (cancelMatch) {
this.emit('cancelRequested', cancelMatch[1]);
return;
}
// Check message (single line): <spawn1337-message agentId="...">content</spawn1337-message>
const msgMatch = trimmed.match(SPAWN_MESSAGE_PATTERN);
if (msgMatch) {
this.emit('messageToChild', msgMatch[1], msgMatch[2]);
return;
}
}
/**
* Check for patterns that might span multiple lines.
* The message tag content can be multiline.
*/
private checkMultiLinePatterns(data: string): void {
if (!data.includes('spawn1337-message')) return;
const msgMatch = data.match(SPAWN_MESSAGE_PATTERN);
if (msgMatch) {
this.emit('messageToChild', msgMatch[1], msgMatch[2]);
}
}
/**
* Emit stateUpdate with debouncing.
*/
private emitStateUpdateDebounced(): void {
this._stateUpdatePending = true;
if (this._stateUpdateTimer) {
clearTimeout(this._stateUpdateTimer);
}
this._stateUpdateTimer = setTimeout(() => {
if (this._stateUpdatePending) {
this._stateUpdatePending = false;
this._stateUpdateTimer = null;
this.emit('stateUpdate', this.state);
}
}, EVENT_DEBOUNCE_MS);
}
/**
* Flush any pending debounced events immediately.
*/
flushPendingEvents(): void {
if (this._stateUpdatePending) {
this._stateUpdatePending = false;
if (this._stateUpdateTimer) {
clearTimeout(this._stateUpdateTimer);
this._stateUpdateTimer = null;
}
this.emit('stateUpdate', this.state);
}
}
/**
* Clear all debounce timers.
*/
private clearDebounceTimers(): void {
if (this._stateUpdateTimer) {
clearTimeout(this._stateUpdateTimer);
this._stateUpdateTimer = null;
}
this._stateUpdatePending = false;
}
}
+907
View File
@@ -0,0 +1,907 @@
/**
* @fileoverview Spawn Orchestrator - Full lifecycle management for spawned agents.
*
* Manages:
* - Agent creation from task spec files
* - Directory setup (CLAUDE.md, comms, workspace)
* - Session spawning via screen
* - Progress monitoring and timeout enforcement
* - Resource governance (tokens, cost, depth)
* - Bidirectional communication
* - Result collection and cleanup
* - Queue management with priority ordering
*
* @module spawn-orchestrator
*/
import { EventEmitter } from 'node:events';
import { join, resolve, isAbsolute } from 'node:path';
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, symlinkSync } from 'node:fs';
import { v4 as uuidv4 } from 'uuid';
import {
type SpawnOrchestratorConfig,
type SpawnTask,
type AgentContext,
type AgentProgress,
type AgentStatusReport,
type SpawnResult,
type SpawnTrackerState,
type SpawnMessage,
type SpawnPersistedState,
createDefaultOrchestratorConfig,
createEmptyAgentProgress,
parseTaskSpecFile,
parseSpawnResult,
MAX_TASK_FILE_SIZE,
MAX_CONTEXT_FILE_SIZE,
MAX_CONTEXT_FILES,
MAX_QUEUE_LENGTH,
BUDGET_WARNING_THRESHOLD,
MESSAGE_MAX_SIZE,
MAX_MESSAGES_PER_CHANNEL,
MAX_TRACKED_AGENTS,
} from './spawn-types.js';
import { generateAgentClaudeMd, buildInitialPrompt } from './spawn-claude-md.js';
import { getErrorMessage } from './types.js';
// ========== Types for integration ==========
/**
* Interface for session creation callback.
* The orchestrator delegates session creation to the server to avoid circular deps.
*/
export interface SessionCreator {
createAgentSession(workingDir: string, name: string): Promise<{ sessionId: string }>;
writeToSession(sessionId: string, data: string): void;
getSessionTokens(sessionId: string): number;
getSessionCost(sessionId: string): number;
stopSession(sessionId: string): Promise<void>;
onSessionCompletion(sessionId: string, handler: (phrase: string) => void): void;
removeSessionCompletionHandler(sessionId: string, handler: (phrase: string) => void): void;
}
// ========== Events ==========
export interface SpawnOrchestratorEvents {
/** Agent added to queue */
queued: (data: { agentId: string; name: string; parentSessionId: string; position: number }) => void;
/** Agent directory being set up */
initializing: (data: { agentId: string; name: string; workingDir: string }) => void;
/** Agent session started */
started: (data: { agentId: string; name: string; sessionId: string }) => void;
/** Agent progress update */
progress: (data: { agentId: string; progress: AgentProgress }) => void;
/** New message in channel */
message: (data: { agentId: string; message: SpawnMessage }) => void;
/** Agent completed successfully */
completed: (data: { agentId: string; result: SpawnResult }) => void;
/** Agent failed */
failed: (data: { agentId: string; error: string; partialProgress: AgentProgress | null }) => void;
/** Agent timed out */
timeout: (data: { agentId: string; elapsed: number; limit: number }) => void;
/** Agent cancelled */
cancelled: (data: { agentId: string; reason: string }) => void;
/** Budget warning */
budgetWarning: (data: { agentId: string; type: 'tokens' | 'cost'; used: number; limit: number }) => void;
/** Overall state changed */
stateUpdate: (state: SpawnTrackerState) => void;
}
/**
* SpawnOrchestrator - Manages the full lifecycle of spawned agents.
*
* Handles agent creation, monitoring, communication, resource governance,
* and cleanup. Integrates with Session, ScreenManager, and RalphTracker.
*/
export class SpawnOrchestrator extends EventEmitter {
private _agents: Map<string, AgentContext> = new Map();
private _completedAgents: Map<string, AgentContext> = new Map();
private _queue: SpawnTask[] = [];
private _config: SpawnOrchestratorConfig;
private _sessionCreator: SessionCreator | null = null;
private _totalSpawned: number = 0;
private _totalCompleted: number = 0;
private _totalFailed: number = 0;
private _maxDepthReached: number = 0;
private _completionHandlers: Map<string, (phrase: string) => void> = new Map();
constructor(config?: Partial<SpawnOrchestratorConfig>) {
super();
this._config = { ...createDefaultOrchestratorConfig(), ...config };
}
/**
* Set the session creator callback.
* Must be called before any spawn requests can be processed.
*/
setSessionCreator(creator: SessionCreator): void {
this._sessionCreator = creator;
}
/**
* Get current orchestrator configuration.
*/
get config(): SpawnOrchestratorConfig {
return { ...this._config };
}
/**
* Update orchestrator configuration.
*/
updateConfig(config: Partial<SpawnOrchestratorConfig>): void {
Object.assign(this._config, config);
}
/**
* Handle a spawn request detected from terminal output.
*
* @param filePath - Path to the task spec file (relative to parent's workingDir)
* @param parentSessionId - ID of the parent session
* @param parentWorkingDir - Working directory of the parent session
* @param parentDepth - Depth of the parent in the spawn tree
*/
async handleSpawnRequest(
filePath: string,
parentSessionId: string,
parentWorkingDir: string,
parentDepth: number = 0
): Promise<void> {
if (!this._sessionCreator) {
console.error('[spawn-orchestrator] No session creator set, cannot spawn agent');
return;
}
// Resolve file path relative to parent's working directory
const resolvedPath = isAbsolute(filePath) ? filePath : join(parentWorkingDir, filePath);
// Validate file exists and size
if (!existsSync(resolvedPath)) {
console.error(`[spawn-orchestrator] Task file not found: ${resolvedPath}`);
this.emit('failed', { agentId: 'unknown', error: `Task file not found: ${resolvedPath}`, partialProgress: null });
return;
}
const stat = statSync(resolvedPath);
if (stat.size > MAX_TASK_FILE_SIZE) {
console.error(`[spawn-orchestrator] Task file too large: ${stat.size} bytes (max ${MAX_TASK_FILE_SIZE})`);
this.emit('failed', { agentId: 'unknown', error: `Task file too large: ${stat.size} bytes`, partialProgress: null });
return;
}
// Parse task file
const content = readFileSync(resolvedPath, 'utf-8');
const fallbackId = `agent-${uuidv4().slice(0, 8)}`;
const parsed = parseTaskSpecFile(content, fallbackId);
if (!parsed) {
console.error(`[spawn-orchestrator] Failed to parse task file: ${resolvedPath}`);
this.emit('failed', { agentId: fallbackId, error: 'Failed to parse task spec YAML frontmatter', partialProgress: null });
return;
}
const childDepth = parentDepth + 1;
// Depth check
if (childDepth > this._config.maxSpawnDepth) {
console.error(`[spawn-orchestrator] Max spawn depth (${this._config.maxSpawnDepth}) exceeded at depth ${childDepth}`);
this.emit('failed', { agentId: parsed.spec.agentId, error: `Max spawn depth exceeded (${this._config.maxSpawnDepth})`, partialProgress: null });
return;
}
// Enforce timeout limits
if (parsed.spec.timeoutMinutes > this._config.maxTimeoutMinutes) {
parsed.spec.timeoutMinutes = this._config.maxTimeoutMinutes;
}
const task: SpawnTask = {
spec: parsed.spec,
instructions: parsed.instructions,
sourceFile: resolvedPath,
parentSessionId,
depth: childDepth,
};
// Check dependencies
if (task.spec.dependsOn && task.spec.dependsOn.length > 0) {
const unmetDeps = task.spec.dependsOn.filter(depId => {
const dep = this._completedAgents.get(depId);
return !dep || dep.status !== 'completed';
});
if (unmetDeps.length > 0) {
// Queue with dependency tracking
this.enqueueTask(task);
return;
}
}
// Concurrency check
const activeCount = this.getActiveCount();
if (activeCount >= this._config.maxConcurrentAgents) {
this.enqueueTask(task);
return;
}
// Spawn immediately
await this.spawnAgent(task);
}
/**
* Cancel an agent by ID.
*/
async cancelAgent(agentId: string, reason: string = 'Cancelled by parent'): Promise<void> {
const agent = this._agents.get(agentId);
if (!agent) {
// Check queue
const queueIdx = this._queue.findIndex(t => t.spec.agentId === agentId);
if (queueIdx >= 0) {
this._queue.splice(queueIdx, 1);
this.emit('cancelled', { agentId, reason: 'Removed from queue' });
this.emitStateUpdate();
}
return;
}
agent.status = 'cancelled';
this.emit('cancelled', { agentId, reason });
await this.cleanupAgent(agentId);
}
/**
* Send a message to an agent.
*/
async sendMessageToAgent(agentId: string, content: string): Promise<void> {
const agent = this._agents.get(agentId);
if (!agent) return;
if (content.length > MESSAGE_MAX_SIZE) {
content = content.slice(0, MESSAGE_MAX_SIZE);
}
const messagesDir = join(agent.commsDir, 'messages');
if (!existsSync(messagesDir)) {
mkdirSync(messagesDir, { recursive: true });
}
// Count existing messages
const existingMessages = readdirSync(messagesDir).filter(f => f.endsWith('.md'));
if (existingMessages.length >= MAX_MESSAGES_PER_CHANNEL) {
return; // Channel full
}
const seq = existingMessages.length + 1;
const seqStr = String(seq).padStart(3, '0');
const fileName = `${seqStr}-parent.md`;
const message: SpawnMessage = {
sequence: seq,
sender: 'parent',
content,
sentAt: Date.now(),
read: false,
};
writeFileSync(join(messagesDir, fileName), content, 'utf-8');
this.emit('message', { agentId, message });
}
/**
* Get status of a specific agent.
*/
getAgentStatus(agentId: string): AgentStatusReport | null {
const agent = this._agents.get(agentId) || this._completedAgents.get(agentId);
if (!agent) return null;
return this.buildStatusReport(agent);
}
/**
* Get status of all agents (active + recently completed).
*/
getAllAgentStatuses(): AgentStatusReport[] {
const reports: AgentStatusReport[] = [];
for (const agent of this._agents.values()) {
reports.push(this.buildStatusReport(agent));
}
for (const agent of this._completedAgents.values()) {
reports.push(this.buildStatusReport(agent));
}
return reports;
}
/**
* Get current orchestrator state.
*/
getState(): SpawnTrackerState {
return {
enabled: true,
activeCount: this.getActiveCount(),
queuedCount: this._queue.length,
totalSpawned: this._totalSpawned,
totalCompleted: this._totalCompleted,
totalFailed: this._totalFailed,
maxDepthReached: this._maxDepthReached,
agents: this.getAllAgentStatuses(),
};
}
/**
* Get state for persistence.
*/
getPersistedState(): SpawnPersistedState {
const agents: SpawnPersistedState['agents'] = {};
for (const [id, agent] of this._agents) {
agents[id] = {
agentId: id,
status: agent.status,
parentSessionId: agent.parentSessionId,
childSessionId: agent.sessionId,
depth: agent.depth,
startedAt: agent.startedAt,
commsDir: agent.commsDir,
workingDir: agent.workingDir,
completionPhrase: agent.task.spec.completionPhrase,
timeoutMinutes: agent.task.spec.timeoutMinutes,
};
}
return { config: this._config, agents };
}
/**
* Stop all agents.
*/
async stopAll(): Promise<void> {
const agentIds = Array.from(this._agents.keys());
for (const agentId of agentIds) {
await this.cancelAgent(agentId, 'Orchestrator shutdown');
}
this._queue = [];
}
/**
* Read an agent's result.md file.
*/
readAgentResult(agentId: string): SpawnResult | null {
const agent = this._agents.get(agentId) || this._completedAgents.get(agentId);
if (!agent) return null;
const resultPath = join(agent.commsDir, 'result.md');
if (!existsSync(resultPath)) return null;
const content = readFileSync(resultPath, 'utf-8');
const durationMs = agent.startedAt ? Date.now() - agent.startedAt : 0;
return parseSpawnResult(content, agentId, durationMs);
}
/**
* Read an agent's progress.json file.
*/
readAgentProgress(agentId: string): AgentProgress | null {
const agent = this._agents.get(agentId) || this._completedAgents.get(agentId);
if (!agent) return null;
const progressPath = join(agent.commsDir, 'progress.json');
if (!existsSync(progressPath)) return null;
try {
const content = readFileSync(progressPath, 'utf-8');
return JSON.parse(content) as AgentProgress;
} catch {
return null;
}
}
/**
* Read messages from an agent's communication channel.
*/
readAgentMessages(agentId: string): SpawnMessage[] {
const agent = this._agents.get(agentId) || this._completedAgents.get(agentId);
if (!agent) return [];
const messagesDir = join(agent.commsDir, 'messages');
if (!existsSync(messagesDir)) return [];
const files = readdirSync(messagesDir)
.filter(f => f.endsWith('.md'))
.sort();
const messages: SpawnMessage[] = [];
for (const file of files) {
const match = file.match(/^(\d+)-(parent|agent)\.md$/);
if (!match) continue;
const content = readFileSync(join(messagesDir, file), 'utf-8');
messages.push({
sequence: parseInt(match[1]),
sender: match[2] as 'parent' | 'agent',
content,
sentAt: statSync(join(messagesDir, file)).mtimeMs,
read: true,
});
}
return messages;
}
/**
* Programmatically trigger a spawn without terminal detection.
*/
async triggerSpawn(
taskContent: string,
parentSessionId: string,
parentWorkingDir: string,
parentDepth: number = 0
): Promise<string | null> {
const fallbackId = `agent-${uuidv4().slice(0, 8)}`;
const parsed = parseTaskSpecFile(taskContent, fallbackId);
if (!parsed) return null;
// If the spec doesn't specify a workingDir, use the parent's
if (!parsed.spec.workingDir) {
parsed.spec.workingDir = parentWorkingDir;
}
// Write task content to a temp file so setupAgentDirectory can read it
const tempDir = join(this._config.casesDir, '.spawn-tmp');
mkdirSync(tempDir, { recursive: true });
const tempFile = join(tempDir, `${parsed.spec.agentId}.md`);
writeFileSync(tempFile, taskContent, 'utf-8');
const task: SpawnTask = {
spec: parsed.spec,
instructions: parsed.instructions,
sourceFile: tempFile,
parentSessionId,
depth: parentDepth + 1,
};
await this.spawnAgent(task);
return task.spec.agentId;
}
// ========== Internal Methods ==========
private getActiveCount(): number {
let count = 0;
for (const agent of this._agents.values()) {
if (agent.status === 'initializing' || agent.status === 'running') {
count++;
}
}
return count;
}
private enqueueTask(task: SpawnTask): void {
if (this._queue.length >= MAX_QUEUE_LENGTH) {
this.emit('failed', {
agentId: task.spec.agentId,
error: `Queue full (max ${MAX_QUEUE_LENGTH})`,
partialProgress: null,
});
return;
}
// Insert by priority (higher priority first)
const priorityOrder = { critical: 0, high: 1, normal: 2, low: 3 };
const taskPriority = priorityOrder[task.spec.priority];
let insertIdx = this._queue.length;
for (let i = 0; i < this._queue.length; i++) {
if (priorityOrder[this._queue[i].spec.priority] > taskPriority) {
insertIdx = i;
break;
}
}
this._queue.splice(insertIdx, 0, task);
this.emit('queued', {
agentId: task.spec.agentId,
name: task.spec.name,
parentSessionId: task.parentSessionId,
position: insertIdx + 1,
});
this.emitStateUpdate();
}
private async spawnAgent(task: SpawnTask): Promise<void> {
if (!this._sessionCreator) return;
const agentId = task.spec.agentId;
this._totalSpawned++;
if (task.depth > this._maxDepthReached) {
this._maxDepthReached = task.depth;
}
// Create agent context
const workingDir = join(this._config.casesDir, `spawn-${agentId}`);
const commsDir = join(workingDir, 'spawn-comms');
const agent: AgentContext = {
task,
sessionId: null,
workingDir,
commsDir,
parentSessionId: task.parentSessionId,
depth: task.depth,
timeoutTimer: null,
progressTimer: null,
status: 'initializing',
startedAt: null,
tokenBudget: task.spec.maxTokens ?? null,
costBudget: task.spec.maxCost ?? null,
};
this._agents.set(agentId, agent);
this.emit('initializing', { agentId, name: task.spec.name, workingDir });
this.emitStateUpdate();
try {
// Setup directory structure
this.setupAgentDirectory(task, workingDir, commsDir);
// Create session
const { sessionId } = await this._sessionCreator.createAgentSession(workingDir, agentId);
agent.sessionId = sessionId;
agent.status = 'running';
agent.startedAt = Date.now();
this.emit('started', { agentId, name: task.spec.name, sessionId });
this.emitStateUpdate();
// Setup completion listener
this.setupCompletionListener(agent);
// Setup progress monitor
this.setupProgressMonitor(agent);
// Setup timeout
this.setupTimeout(agent);
// Inject initial prompt (short delay to let session initialize)
setTimeout(() => {
if (agent.status === 'running' && this._sessionCreator) {
const prompt = buildInitialPrompt(task);
this._sessionCreator.writeToSession(sessionId, prompt + '\r');
}
}, 3000);
} catch (err) {
agent.status = 'failed';
this._totalFailed++;
this.emit('failed', { agentId, error: getErrorMessage(err), partialProgress: null });
await this.cleanupAgent(agentId);
}
}
private setupAgentDirectory(task: SpawnTask, workingDir: string, commsDir: string): void {
// Create directory structure
mkdirSync(workingDir, { recursive: true });
mkdirSync(commsDir, { recursive: true });
mkdirSync(join(commsDir, 'messages'), { recursive: true });
mkdirSync(join(commsDir, 'artifacts'), { recursive: true });
mkdirSync(join(workingDir, 'workspace'), { recursive: true });
// Copy task.md to comms
writeFileSync(join(commsDir, 'task.md'), readFileSync(task.sourceFile, 'utf-8'), 'utf-8');
// Write initial progress.json
writeFileSync(
join(commsDir, 'progress.json'),
JSON.stringify(createEmptyAgentProgress(), null, 2),
'utf-8'
);
// Generate and write CLAUDE.md
const claudeMd = generateAgentClaudeMd(task, commsDir, workingDir);
writeFileSync(join(workingDir, 'CLAUDE.md'), claudeMd, 'utf-8');
// Symlink context files into workspace
if (task.spec.contextFiles && task.spec.contextFiles.length > 0) {
const parentWorkingDir = this.resolveParentWorkingDir(task);
let fileCount = 0;
for (const contextFile of task.spec.contextFiles) {
if (fileCount >= MAX_CONTEXT_FILES) break;
const sourcePath = isAbsolute(contextFile)
? contextFile
: join(parentWorkingDir, contextFile);
if (!existsSync(sourcePath)) continue;
const stat = statSync(sourcePath);
if (stat.size > MAX_CONTEXT_FILE_SIZE) continue;
const destPath = join(workingDir, 'workspace', contextFile.split('/').pop() || contextFile);
try {
symlinkSync(sourcePath, destPath);
fileCount++;
} catch {
// Ignore symlink errors (e.g., dest already exists)
}
}
}
}
private resolveParentWorkingDir(task: SpawnTask): string {
// If the task has a specified workingDir, resolve it
if (task.spec.workingDir) {
return isAbsolute(task.spec.workingDir)
? task.spec.workingDir
: resolve(this._config.casesDir, task.spec.workingDir);
}
// Default: use casesDir
return this._config.casesDir;
}
private setupCompletionListener(agent: AgentContext): void {
if (!this._sessionCreator || !agent.sessionId) return;
const handler = (phrase: string) => {
if (phrase === agent.task.spec.completionPhrase) {
this.handleAgentCompletion(agent);
}
};
this._completionHandlers.set(agent.task.spec.agentId, handler);
this._sessionCreator.onSessionCompletion(agent.sessionId, handler);
}
private setupProgressMonitor(agent: AgentContext): void {
if (this._config.progressPollIntervalMs <= 0) return;
agent.progressTimer = setInterval(() => {
if (agent.status !== 'running') return;
// Read progress
const progress = this.readAgentProgress(agent.task.spec.agentId);
if (progress) {
this.emit('progress', { agentId: agent.task.spec.agentId, progress });
}
// Check resource budgets
this.checkResourceBudgets(agent);
}, this._config.progressPollIntervalMs);
}
private setupTimeout(agent: AgentContext): void {
const timeoutMs = agent.task.spec.timeoutMinutes * 60 * 1000;
// Warning at 90%
const warningMs = timeoutMs * 0.9;
setTimeout(() => {
if (agent.status === 'running' && this._sessionCreator && agent.sessionId) {
this._sessionCreator.writeToSession(
agent.sessionId,
'WARNING: You have less than 10% of your timeout remaining. Please wrap up and write your result.md soon.\r'
);
}
}, warningMs);
// Hard timeout
agent.timeoutTimer = setTimeout(() => {
if (agent.status === 'running') {
this.handleAgentTimeout(agent);
}
}, timeoutMs);
}
private checkResourceBudgets(agent: AgentContext): void {
if (!this._sessionCreator || !agent.sessionId) return;
// Token budget
if (agent.tokenBudget !== null) {
const tokensUsed = this._sessionCreator.getSessionTokens(agent.sessionId);
const ratio = tokensUsed / agent.tokenBudget;
if (ratio >= 1.1) {
// Force kill at 110%
this.handleAgentTimeout(agent);
return;
} else if (ratio >= 1.0) {
// Graceful shutdown
this._sessionCreator.writeToSession(
agent.sessionId,
'You have exceeded your token budget. Write your result.md NOW and output your completion phrase.\r'
);
} else if (ratio >= BUDGET_WARNING_THRESHOLD) {
this.emit('budgetWarning', {
agentId: agent.task.spec.agentId,
type: 'tokens',
used: tokensUsed,
limit: agent.tokenBudget,
});
}
}
// Cost budget
if (agent.costBudget !== null) {
const costUsed = this._sessionCreator.getSessionCost(agent.sessionId);
const ratio = costUsed / agent.costBudget;
if (ratio >= 1.1) {
this.handleAgentTimeout(agent);
return;
} else if (ratio >= 1.0) {
this._sessionCreator.writeToSession(
agent.sessionId,
'You have exceeded your cost budget. Write your result.md NOW and output your completion phrase.\r'
);
} else if (ratio >= BUDGET_WARNING_THRESHOLD) {
this.emit('budgetWarning', {
agentId: agent.task.spec.agentId,
type: 'cost',
used: costUsed,
limit: agent.costBudget,
});
}
}
}
private async handleAgentCompletion(agent: AgentContext): Promise<void> {
if (agent.status !== 'running') return;
agent.status = 'completing';
this._totalCompleted++;
// Read result
const result = this.readAgentResult(agent.task.spec.agentId);
if (result) {
// Update token/cost from session
if (this._sessionCreator && agent.sessionId) {
result.tokens.total = this._sessionCreator.getSessionTokens(agent.sessionId);
result.cost = this._sessionCreator.getSessionCost(agent.sessionId);
}
this.emit('completed', { agentId: agent.task.spec.agentId, result });
} else {
// No result file found, create a minimal one
const minimalResult: SpawnResult = {
status: 'completed',
durationMs: agent.startedAt ? Date.now() - agent.startedAt : 0,
tokens: { input: 0, output: 0, total: 0 },
cost: 0,
summary: 'Agent completed but no result.md was found',
output: '',
filesChanged: [],
agentId: agent.task.spec.agentId,
completedAt: Date.now(),
};
this.emit('completed', { agentId: agent.task.spec.agentId, result: minimalResult });
}
agent.status = 'completed';
await this.cleanupAgent(agent.task.spec.agentId);
this.processQueue();
}
private async handleAgentTimeout(agent: AgentContext): Promise<void> {
if (agent.status !== 'running') return;
agent.status = 'timeout';
this._totalFailed++;
const elapsed = agent.startedAt ? Date.now() - agent.startedAt : 0;
const limit = agent.task.spec.timeoutMinutes * 60 * 1000;
this.emit('timeout', { agentId: agent.task.spec.agentId, elapsed, limit });
await this.cleanupAgent(agent.task.spec.agentId);
this.processQueue();
}
private async cleanupAgent(agentId: string): Promise<void> {
const agent = this._agents.get(agentId);
if (!agent) return;
// Clear timers
if (agent.timeoutTimer) {
clearTimeout(agent.timeoutTimer);
agent.timeoutTimer = null;
}
if (agent.progressTimer) {
clearInterval(agent.progressTimer);
agent.progressTimer = null;
}
// Remove completion handler
const handler = this._completionHandlers.get(agentId);
if (handler && this._sessionCreator && agent.sessionId) {
this._sessionCreator.removeSessionCompletionHandler(agent.sessionId, handler);
this._completionHandlers.delete(agentId);
}
// Stop session
if (agent.sessionId && this._sessionCreator) {
try {
await this._sessionCreator.stopSession(agent.sessionId);
} catch {
// Ignore cleanup errors
}
}
// Move to completed (LRU)
this._agents.delete(agentId);
this._completedAgents.set(agentId, agent);
// LRU eviction for completed agents
if (this._completedAgents.size > MAX_TRACKED_AGENTS) {
const firstKey = this._completedAgents.keys().next().value;
if (firstKey) this._completedAgents.delete(firstKey);
}
this.emitStateUpdate();
}
private processQueue(): void {
while (this._queue.length > 0 && this.getActiveCount() < this._config.maxConcurrentAgents) {
const task = this._queue.shift();
if (!task) break;
// Re-check dependencies
if (task.spec.dependsOn && task.spec.dependsOn.length > 0) {
const unmetDeps = task.spec.dependsOn.filter(depId => {
const dep = this._completedAgents.get(depId);
return !dep || dep.status !== 'completed';
});
if (unmetDeps.length > 0) {
// Put back in queue
this._queue.unshift(task);
break;
}
}
// Spawn (async, don't await to allow multiple spawns)
this.spawnAgent(task).catch(err => {
console.error(`[spawn-orchestrator] Failed to spawn queued agent: ${getErrorMessage(err)}`);
});
}
}
private buildStatusReport(agent: AgentContext): AgentStatusReport {
const now = Date.now();
const elapsed = agent.startedAt ? now - agent.startedAt : 0;
const timeoutMs = agent.task.spec.timeoutMinutes * 60 * 1000;
const timeRemaining = agent.startedAt ? Math.max(0, timeoutMs - elapsed) : timeoutMs;
let tokensUsed = 0;
let costSoFar = 0;
if (agent.sessionId && this._sessionCreator) {
tokensUsed = this._sessionCreator.getSessionTokens(agent.sessionId);
costSoFar = this._sessionCreator.getSessionCost(agent.sessionId);
}
// Check dependency status
let dependencyStatus: 'waiting' | 'ready' | 'n/a' = 'n/a';
if (agent.task.spec.dependsOn && agent.task.spec.dependsOn.length > 0) {
const allMet = agent.task.spec.dependsOn.every(depId => {
const dep = this._completedAgents.get(depId);
return dep && dep.status === 'completed';
});
dependencyStatus = allMet ? 'ready' : 'waiting';
}
return {
agentId: agent.task.spec.agentId,
name: agent.task.spec.name,
type: agent.task.spec.type,
status: agent.status,
priority: agent.task.spec.priority,
parentSessionId: agent.parentSessionId,
childSessionId: agent.sessionId,
depth: agent.depth,
startedAt: agent.startedAt,
elapsedMs: elapsed,
progress: this.readAgentProgress(agent.task.spec.agentId),
tokensUsed,
costSoFar,
tokenBudget: agent.tokenBudget,
costBudget: agent.costBudget,
timeoutMinutes: agent.task.spec.timeoutMinutes,
timeRemainingMs: timeRemaining,
completionPhrase: agent.task.spec.completionPhrase,
dependsOn: agent.task.spec.dependsOn || [],
dependencyStatus,
};
}
private emitStateUpdate(): void {
this.emit('stateUpdate', this.getState());
}
}
+698
View File
@@ -0,0 +1,698 @@
/**
* @fileoverview Type definitions for the spawn1337 Autonomous Agent Protocol.
*
* Defines all types for the agent spawning system including:
* - Task specifications (what the parent writes)
* - Agent progress reporting
* - Result delivery format
* - Bidirectional messaging
* - Orchestrator state tracking
*
* Also includes a simple YAML frontmatter parser and factory functions.
*
* @module spawn-types
*/
// ========== Agent Task Specification ==========
/** Priority levels for spawn tasks */
export type SpawnPriority = 'low' | 'normal' | 'high' | 'critical';
/** How results should be delivered */
export type SpawnResultDelivery = 'file' | 'notify' | 'both';
/** Agent execution status */
export type SpawnStatus = 'queued' | 'initializing' | 'running' | 'completing' | 'completed' | 'failed' | 'timeout' | 'cancelled';
/**
* Task specification parsed from the .md file's YAML frontmatter.
* This is the contract for what the parent LLM writes.
*/
export interface SpawnTaskSpec {
// === Identity ===
/** Unique agent identifier (auto-generated if not provided) */
agentId: string;
/** Human-readable name for this agent */
name: string;
/** Task type/category */
type: 'explore' | 'implement' | 'test' | 'review' | 'refactor' | 'research' | 'generate' | 'fix' | 'general';
// === Scheduling ===
/** Priority for queue ordering */
priority: SpawnPriority;
/** Dependencies - other agentIds that must complete first */
dependsOn?: string[];
// === Environment ===
/** Working directory (relative to parent, or absolute) */
workingDir?: string;
/** Files to copy/symlink into agent workspace as context */
contextFiles?: string[];
/** Whether the agent can modify files in the parent's directory */
canModifyParentFiles: boolean;
/** Additional environment variables for the agent */
env?: Record<string, string>;
// === Resource Governance ===
/** Maximum token budget (input + output combined) */
maxTokens?: number;
/** Maximum cost in USD */
maxCost?: number;
/** Timeout in minutes */
timeoutMinutes: number;
// === Communication ===
/** How to deliver results */
resultDelivery: SpawnResultDelivery;
/** Completion phrase for RalphTracker (auto-generated if not set) */
completionPhrase: string;
/** How often the agent should report progress (seconds, 0 = no progress) */
progressIntervalSeconds: number;
// === Output ===
/** Expected output format */
outputFormat: 'markdown' | 'json' | 'code' | 'structured' | 'freeform';
/** Success criteria (included in agent's CLAUDE.md) */
successCriteria: string;
}
/**
* The full parsed task (spec + instructions body).
*/
export interface SpawnTask {
spec: SpawnTaskSpec;
/** The markdown body - actual instructions for the agent */
instructions: string;
/** Source file path */
sourceFile: string;
/** Parent session ID that requested this spawn */
parentSessionId: string;
/** Spawn depth (0 = direct child of user) */
depth: number;
}
// ========== Agent Communication ==========
/**
* Progress report written by agent to spawn-comms/progress.json
*/
export interface AgentProgress {
/** Current phase/step description */
phase: string;
/** Completion percentage (0-100) */
percentComplete: number;
/** What the agent is currently doing */
currentAction: string;
/** Todos/subtasks the agent is tracking */
subtasks?: Array<{
description: string;
status: 'pending' | 'in_progress' | 'completed';
}>;
/** Timestamp of last update */
updatedAt: number;
/** Files modified so far */
filesModified: string[];
/** Tokens used so far */
tokensUsed: number;
/** Cost so far */
costSoFar: number;
}
/**
* Result delivered by agent on completion.
* Written to spawn-comms/result.md as YAML frontmatter + body.
*/
export interface SpawnResult {
// === Status ===
/** Final execution status */
status: 'completed' | 'failed' | 'timeout' | 'cancelled';
/** Error message if failed */
error?: string;
// === Metrics ===
/** Total execution duration in ms */
durationMs: number;
/** Token usage breakdown */
tokens: {
input: number;
output: number;
total: number;
};
/** Total cost in USD */
cost: number;
// === Output ===
/** Executive summary (1-3 sentences) */
summary: string;
/** Full structured output */
output: string;
/** Files modified or created */
filesChanged: Array<{
path: string;
action: 'created' | 'modified' | 'deleted';
summary?: string;
}>;
/** Any artifacts produced (data files, diagrams, etc.) */
artifacts?: Array<{
name: string;
path: string;
type: string;
description: string;
}>;
// === Metadata ===
/** Agent ID */
agentId: string;
/** Completion timestamp */
completedAt: number;
/** Number of respawn cycles if agent used Ralph loop */
cycleCount?: number;
}
/**
* Message in the bidirectional communication channel.
* Written to spawn-comms/messages/NNN-{sender}.md
*/
export interface SpawnMessage {
/** Sequential message number */
sequence: number;
/** Who sent it */
sender: 'parent' | 'agent';
/** Message content (markdown) */
content: string;
/** Timestamp */
sentAt: number;
/** Whether it's been read by the recipient */
read: boolean;
}
/**
* Status report for UI display and API responses.
*/
export interface AgentStatusReport {
agentId: string;
name: string;
type: string;
status: SpawnStatus;
priority: SpawnPriority;
parentSessionId: string;
childSessionId: string | null;
depth: number;
startedAt: number | null;
elapsedMs: number;
progress: AgentProgress | null;
tokensUsed: number;
costSoFar: number;
tokenBudget: number | null;
costBudget: number | null;
timeoutMinutes: number;
timeRemainingMs: number;
completionPhrase: string;
dependsOn: string[];
dependencyStatus: 'waiting' | 'ready' | 'n/a';
}
// ========== Tracker State (for SpawnDetector) ==========
export interface SpawnTrackerState {
enabled: boolean;
activeCount: number;
queuedCount: number;
totalSpawned: number;
totalCompleted: number;
totalFailed: number;
maxDepthReached: number;
agents: AgentStatusReport[];
}
// ========== Orchestrator Configuration ==========
export interface SpawnOrchestratorConfig {
/** Max concurrent agent sessions (default: 5) */
maxConcurrentAgents: number;
/** Base directory for agent cases (default: ~/claudeman-cases/) */
casesDir: string;
/** Default timeout in minutes (default: 30) */
defaultTimeoutMinutes: number;
/** Max timeout allowed in minutes (default: 120) */
maxTimeoutMinutes: number;
/** Max agent tree depth (prevent infinite recursion) (default: 3) */
maxSpawnDepth: number;
/** Progress poll interval in ms (default: 5000) */
progressPollIntervalMs: number;
}
// ========== Agent Context (internal orchestrator state) ==========
export interface AgentContext {
/** The parsed task specification */
task: SpawnTask;
/** The spawned session ID (set after session creation) */
sessionId: string | null;
/** Resolved working directory for this agent */
workingDir: string;
/** Communication directory path */
commsDir: string;
/** Parent session ID */
parentSessionId: string;
/** Depth in the spawn tree (0 = direct child of user session) */
depth: number;
/** Timeout timer handle */
timeoutTimer: NodeJS.Timeout | null;
/** Progress poll timer handle */
progressTimer: NodeJS.Timeout | null;
/** Current status */
status: SpawnStatus;
/** When the agent started working */
startedAt: number | null;
/** Token budget remaining (null = unlimited) */
tokenBudget: number | null;
/** Cost budget remaining (null = unlimited) */
costBudget: number | null;
}
// ========== Persisted State ==========
export interface SpawnPersistedState {
config: SpawnOrchestratorConfig;
agents: Record<string, {
agentId: string;
status: SpawnStatus;
parentSessionId: string;
childSessionId: string | null;
depth: number;
startedAt: number | null;
commsDir: string;
workingDir: string;
completionPhrase: string;
timeoutMinutes: number;
}>;
}
// ========== Constants ==========
/** Maximum concurrent agents */
export const MAX_CONCURRENT_AGENTS = 5;
/** Default timeout in minutes */
export const DEFAULT_TIMEOUT_MINUTES = 30;
/** Maximum timeout in minutes */
export const MAX_TIMEOUT_MINUTES = 120;
/** Maximum spawn depth */
export const MAX_SPAWN_DEPTH = 3;
/** Progress poll interval in ms */
export const PROGRESS_POLL_INTERVAL_MS = 5000;
/** Maximum task file size (2MB) */
export const MAX_TASK_FILE_SIZE = 2 * 1024 * 1024;
/** Maximum context file size (100KB each) */
export const MAX_CONTEXT_FILE_SIZE = 100 * 1024;
/** Maximum number of context files */
export const MAX_CONTEXT_FILES = 20;
/** Maximum queue length */
export const MAX_QUEUE_LENGTH = 50;
/** Budget warning threshold (80%) */
export const BUDGET_WARNING_THRESHOLD = 0.8;
/** Budget grace period in seconds */
export const BUDGET_GRACE_PERIOD_S = 60;
/** Agent name max length */
export const AGENT_NAME_MAX_LENGTH = 64;
/** Message max size (50KB) */
export const MESSAGE_MAX_SIZE = 50 * 1024;
/** Max messages per channel */
export const MAX_MESSAGES_PER_CHANNEL = 100;
/** Max tracked agents (LRU) */
export const MAX_TRACKED_AGENTS = 200;
// ========== Factory Functions ==========
/**
* Creates a default SpawnTaskSpec with sensible defaults.
*/
export function createDefaultSpawnTaskSpec(agentId: string): SpawnTaskSpec {
return {
agentId,
name: agentId,
type: 'general',
priority: 'normal',
canModifyParentFiles: false,
timeoutMinutes: DEFAULT_TIMEOUT_MINUTES,
resultDelivery: 'both',
completionPhrase: `AGENT_${agentId.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_DONE`,
progressIntervalSeconds: 30,
outputFormat: 'markdown',
successCriteria: '',
};
}
/**
* Creates an empty AgentProgress object.
*/
export function createEmptyAgentProgress(): AgentProgress {
return {
phase: 'initializing',
percentComplete: 0,
currentAction: '',
subtasks: [],
updatedAt: Date.now(),
filesModified: [],
tokensUsed: 0,
costSoFar: 0,
};
}
/**
* Creates initial SpawnTrackerState.
*/
export function createInitialSpawnTrackerState(): SpawnTrackerState {
return {
enabled: false,
activeCount: 0,
queuedCount: 0,
totalSpawned: 0,
totalCompleted: 0,
totalFailed: 0,
maxDepthReached: 0,
agents: [],
};
}
/**
* Creates default orchestrator config.
*/
export function createDefaultOrchestratorConfig(): SpawnOrchestratorConfig {
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
return {
maxConcurrentAgents: MAX_CONCURRENT_AGENTS,
casesDir: `${homeDir}/claudeman-cases`,
defaultTimeoutMinutes: DEFAULT_TIMEOUT_MINUTES,
maxTimeoutMinutes: MAX_TIMEOUT_MINUTES,
maxSpawnDepth: MAX_SPAWN_DEPTH,
progressPollIntervalMs: PROGRESS_POLL_INTERVAL_MS,
};
}
// ========== YAML Frontmatter Parser ==========
/**
* Simple YAML frontmatter parser for task spec files.
* Handles: strings, numbers, booleans, arrays (block and inline), one-level nested objects.
* Does NOT handle: multi-line strings, anchors, aliases, complex nesting.
*/
export function parseYamlFrontmatter(content: string): { frontmatter: Record<string, unknown>; body: string } | null {
const lines = content.split('\n');
// Must start with ---
if (lines[0].trim() !== '---') return null;
let endIndex = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '---') {
endIndex = i;
break;
}
}
if (endIndex === -1) return null;
const yamlLines = lines.slice(1, endIndex);
const body = lines.slice(endIndex + 1).join('\n').trim();
const frontmatter: Record<string, unknown> = {};
let currentKey: string | null = null;
let currentArray: unknown[] | null = null;
let currentObject: Record<string, unknown> | null = null;
for (const line of yamlLines) {
// Skip empty lines and comments
if (!line.trim() || line.trim().startsWith('#')) continue;
const indent = line.length - line.trimStart().length;
// Array item (indented with -)
if (indent >= 2 && line.trim().startsWith('- ')) {
const value = line.trim().slice(2).trim();
if (currentArray && currentKey) {
// Check if it's a key: value pair within an array item
const kvMatch = value.match(/^(\w+):\s*(.+)$/);
if (kvMatch && currentArray.length > 0 && typeof currentArray[currentArray.length - 1] === 'object') {
// Add to existing object in array
(currentArray[currentArray.length - 1] as Record<string, unknown>)[kvMatch[1]] = parseYamlValue(kvMatch[2]);
} else if (kvMatch && value.includes(':')) {
// New object in array
const obj: Record<string, unknown> = {};
obj[kvMatch[1]] = parseYamlValue(kvMatch[2]);
currentArray.push(obj);
} else {
currentArray.push(parseYamlValue(value));
}
}
continue;
}
// Indented key: value (nested object or additional array object fields)
if (indent >= 2 && currentKey && !line.trim().startsWith('- ')) {
const kvMatch = line.trim().match(/^(\w+):\s*(.*)$/);
if (kvMatch) {
// Switch from array mode to object mode if array is empty
if (currentArray && currentArray.length === 0 && !currentObject) {
currentArray = null;
currentObject = {};
}
if (!currentObject) {
currentObject = {};
}
currentObject[kvMatch[1]] = parseYamlValue(kvMatch[2]);
}
continue;
}
// Top-level key: value
const topMatch = line.match(/^(\w+):\s*(.*)$/);
if (topMatch) {
// Save previous array/object
if (currentKey && currentArray) {
frontmatter[currentKey] = currentArray;
} else if (currentKey && currentObject) {
frontmatter[currentKey] = currentObject;
}
currentKey = topMatch[1];
const value = topMatch[2].trim();
if (value === '' || value === '[]') {
// Could be start of array or object
currentArray = [];
currentObject = null;
if (value === '[]') {
frontmatter[currentKey] = [];
currentKey = null;
currentArray = null;
}
} else if (value.startsWith('[') && value.endsWith(']')) {
// Inline array
const items = value.slice(1, -1).split(',').map(s => parseYamlValue(s.trim()));
frontmatter[currentKey] = items;
currentKey = null;
currentArray = null;
currentObject = null;
} else {
frontmatter[currentKey] = parseYamlValue(value);
currentKey = null;
currentArray = null;
currentObject = null;
}
}
}
// Save last pending array/object
if (currentKey && currentArray) {
frontmatter[currentKey] = currentArray;
} else if (currentKey && currentObject) {
frontmatter[currentKey] = currentObject;
}
return { frontmatter, body };
}
/**
* Parse a single YAML value string into the appropriate JS type.
*/
function parseYamlValue(value: string): unknown {
if (!value || value === '~' || value === 'null') return null;
// Remove surrounding quotes
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
return value.slice(1, -1);
}
// Booleans
if (value === 'true' || value === 'yes') return true;
if (value === 'false' || value === 'no') return false;
// Numbers
const num = Number(value);
if (!isNaN(num) && value !== '') return num;
return value;
}
/**
* Parse a task spec file content into a SpawnTaskSpec.
* Returns null if parsing fails.
*/
export function parseTaskSpecFile(content: string, fallbackAgentId: string): { spec: SpawnTaskSpec; instructions: string } | null {
const parsed = parseYamlFrontmatter(content);
if (!parsed) return null;
const { frontmatter, body } = parsed;
const defaults = createDefaultSpawnTaskSpec(fallbackAgentId);
const spec: SpawnTaskSpec = {
agentId: String(frontmatter.agentId ?? defaults.agentId),
name: String(frontmatter.name ?? defaults.name),
type: validateType(frontmatter.type) ?? defaults.type,
priority: validatePriority(frontmatter.priority) ?? defaults.priority,
dependsOn: Array.isArray(frontmatter.dependsOn) ? frontmatter.dependsOn.map(String) : undefined,
workingDir: frontmatter.workingDir != null ? String(frontmatter.workingDir) : undefined,
contextFiles: Array.isArray(frontmatter.contextFiles) ? frontmatter.contextFiles.map(String) : undefined,
canModifyParentFiles: Boolean(frontmatter.canModifyParentFiles ?? defaults.canModifyParentFiles),
env: isStringRecord(frontmatter.env) ? frontmatter.env : undefined,
maxTokens: typeof frontmatter.maxTokens === 'number' ? frontmatter.maxTokens : undefined,
maxCost: typeof frontmatter.maxCost === 'number' ? frontmatter.maxCost : undefined,
timeoutMinutes: typeof frontmatter.timeoutMinutes === 'number' ? frontmatter.timeoutMinutes : defaults.timeoutMinutes,
resultDelivery: validateResultDelivery(frontmatter.resultDelivery) ?? defaults.resultDelivery,
completionPhrase: String(frontmatter.completionPhrase ?? defaults.completionPhrase),
progressIntervalSeconds: typeof frontmatter.progressIntervalSeconds === 'number' ? frontmatter.progressIntervalSeconds : defaults.progressIntervalSeconds,
outputFormat: validateOutputFormat(frontmatter.outputFormat) ?? defaults.outputFormat,
successCriteria: String(frontmatter.successCriteria ?? defaults.successCriteria),
};
// Validate agent name length
if (spec.name.length > AGENT_NAME_MAX_LENGTH) {
spec.name = spec.name.slice(0, AGENT_NAME_MAX_LENGTH);
}
return { spec, instructions: body };
}
// ========== Validation Helpers ==========
const VALID_TYPES = ['explore', 'implement', 'test', 'review', 'refactor', 'research', 'generate', 'fix', 'general'] as const;
const VALID_PRIORITIES = ['low', 'normal', 'high', 'critical'] as const;
const VALID_RESULT_DELIVERIES = ['file', 'notify', 'both'] as const;
const VALID_OUTPUT_FORMATS = ['markdown', 'json', 'code', 'structured', 'freeform'] as const;
function validateType(value: unknown): SpawnTaskSpec['type'] | null {
return VALID_TYPES.includes(value as typeof VALID_TYPES[number]) ? value as SpawnTaskSpec['type'] : null;
}
function validatePriority(value: unknown): SpawnPriority | null {
return VALID_PRIORITIES.includes(value as typeof VALID_PRIORITIES[number]) ? value as SpawnPriority : null;
}
function validateResultDelivery(value: unknown): SpawnResultDelivery | null {
return VALID_RESULT_DELIVERIES.includes(value as typeof VALID_RESULT_DELIVERIES[number]) ? value as SpawnResultDelivery : null;
}
function validateOutputFormat(value: unknown): SpawnTaskSpec['outputFormat'] | null {
return VALID_OUTPUT_FORMATS.includes(value as typeof VALID_OUTPUT_FORMATS[number]) ? value as SpawnTaskSpec['outputFormat'] : null;
}
function isStringRecord(value: unknown): value is Record<string, string> {
if (!value || typeof value !== 'object') return false;
return Object.values(value).every(v => typeof v === 'string');
}
/**
* Serialize a SpawnResult to YAML frontmatter + markdown body.
*/
export function serializeSpawnResult(result: SpawnResult): string {
const lines: string[] = ['---'];
lines.push(`status: ${result.status}`);
if (result.error) lines.push(`error: "${result.error.replace(/"/g, '\\"')}"`);
lines.push(`summary: "${result.summary.replace(/"/g, '\\"')}"`);
lines.push(`durationMs: ${result.durationMs}`);
lines.push(`cost: ${result.cost}`);
lines.push(`agentId: ${result.agentId}`);
lines.push(`completedAt: ${result.completedAt}`);
if (result.cycleCount != null) lines.push(`cycleCount: ${result.cycleCount}`);
if (result.filesChanged.length > 0) {
lines.push('filesChanged:');
for (const file of result.filesChanged) {
lines.push(` - path: ${file.path}`);
lines.push(` action: ${file.action}`);
if (file.summary) lines.push(` summary: "${file.summary.replace(/"/g, '\\"')}"`);
}
} else {
lines.push('filesChanged: []');
}
if (result.artifacts && result.artifacts.length > 0) {
lines.push('artifacts:');
for (const artifact of result.artifacts) {
lines.push(` - name: ${artifact.name}`);
lines.push(` path: ${artifact.path}`);
lines.push(` type: ${artifact.type}`);
lines.push(` description: "${artifact.description.replace(/"/g, '\\"')}"`);
}
}
lines.push('---');
lines.push('');
lines.push(result.output);
return lines.join('\n');
}
/**
* Parse a result.md file into a SpawnResult.
*/
export function parseSpawnResult(content: string, agentId: string, fallbackDurationMs: number): SpawnResult | null {
const parsed = parseYamlFrontmatter(content);
if (!parsed) return null;
const { frontmatter, body } = parsed;
return {
status: (['completed', 'failed', 'timeout', 'cancelled'].includes(String(frontmatter.status))
? String(frontmatter.status) as SpawnResult['status']
: 'completed'),
error: frontmatter.error != null ? String(frontmatter.error) : undefined,
durationMs: typeof frontmatter.durationMs === 'number' ? frontmatter.durationMs : fallbackDurationMs,
tokens: {
input: 0,
output: 0,
total: 0,
},
cost: typeof frontmatter.cost === 'number' ? frontmatter.cost : 0,
summary: String(frontmatter.summary ?? 'No summary provided'),
output: body,
filesChanged: Array.isArray(frontmatter.filesChanged)
? frontmatter.filesChanged.map((f: unknown) => {
if (typeof f === 'object' && f !== null) {
const obj = f as Record<string, unknown>;
return {
path: String(obj.path ?? ''),
action: (['created', 'modified', 'deleted'].includes(String(obj.action)) ? String(obj.action) : 'modified') as 'created' | 'modified' | 'deleted',
summary: obj.summary != null ? String(obj.summary) : undefined,
};
}
return { path: String(f), action: 'modified' as const };
})
: [],
artifacts: Array.isArray(frontmatter.artifacts)
? frontmatter.artifacts.map((a: unknown) => {
const obj = a as Record<string, unknown>;
return {
name: String(obj.name ?? ''),
path: String(obj.path ?? ''),
type: String(obj.type ?? 'unknown'),
description: String(obj.description ?? ''),
};
})
: undefined,
agentId,
completedAt: typeof frontmatter.completedAt === 'number' ? frontmatter.completedAt : Date.now(),
cycleCount: typeof frontmatter.cycleCount === 'number' ? frontmatter.cycleCount : undefined,
};
}
+22
View File
@@ -82,6 +82,10 @@ export interface SessionState {
ralphEnabled?: boolean;
/** Ralph completion phrase (if set) */
ralphCompletionPhrase?: string;
/** Parent agent ID if this session is a spawned agent */
parentAgentId?: string;
/** Child agent IDs spawned by this session */
childAgentIds?: string[];
}
// ========== Task Types ==========
@@ -722,3 +726,21 @@ export function getErrorMessage(error: unknown): string {
}
return 'An unknown error occurred';
}
// ========== Spawn1337 Protocol Re-exports ==========
export type {
SpawnPriority,
SpawnResultDelivery,
SpawnStatus,
SpawnTaskSpec,
SpawnTask,
AgentProgress,
SpawnResult,
SpawnMessage,
AgentStatusReport,
SpawnTrackerState,
SpawnOrchestratorConfig,
AgentContext,
SpawnPersistedState,
} from './spawn-types.js';
+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);