mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
feat: replace SpawnDetector with MCP server for spawn1337 protocol
Instead of parsing terminal output for <spawn1337> tags, spawn capabilities are now exposed as native MCP tools that Claude Code can call directly. The MCP server (stdio transport) proxies requests to the existing REST API. - Add src/mcp-server.ts with 6 tools: spawn_agent, list_agents, get_agent_status, get_agent_result, send_agent_message, cancel_agent - Remove src/spawn-detector.ts and all references in session.ts/server.ts - Add CLAUDEMAN_API_URL env var propagation to sessions and screens - Write .mcp.json to case directories during creation - Remove spawn1337 tag documentation from case-template.md - Add claudeman-mcp bin entry to package.json Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @fileoverview MCP Server for Claudeman Spawn1337 protocol.
|
||||
*
|
||||
* Exposes spawn capabilities as native MCP tools that Claude Code can call
|
||||
* directly, replacing the terminal-tag-parsing approach (SpawnDetector).
|
||||
*
|
||||
* Tools:
|
||||
* - spawn_agent: Spawn a new autonomous agent
|
||||
* - list_agents: List all agents (active + completed)
|
||||
* - get_agent_status: Get detailed agent status
|
||||
* - get_agent_result: Read a completed agent's result
|
||||
* - send_agent_message: Send a message to a running agent
|
||||
* - cancel_agent: Cancel a running agent
|
||||
*
|
||||
* Environment:
|
||||
* - CLAUDEMAN_API_URL: Base URL for the Claudeman API (default: http://localhost:3000)
|
||||
* - CLAUDEMAN_SESSION_ID: Session ID of the calling Claude session
|
||||
*
|
||||
* @module mcp-server
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
// ========== Configuration ==========
|
||||
|
||||
const API_URL = process.env.CLAUDEMAN_API_URL || 'http://localhost:3000';
|
||||
const SESSION_ID = process.env.CLAUDEMAN_SESSION_ID || '';
|
||||
|
||||
// ========== API Helper ==========
|
||||
|
||||
/**
|
||||
* Make an HTTP request to the Claudeman API.
|
||||
*/
|
||||
async function apiRequest(method: string, path: string, body?: unknown): Promise<{ status: number; data: unknown }> {
|
||||
const url = `${API_URL}${path}`;
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (body !== undefined) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json();
|
||||
return { status: response.status, data };
|
||||
}
|
||||
|
||||
// ========== YAML Construction ==========
|
||||
|
||||
/**
|
||||
* Build a YAML frontmatter + body task spec from structured parameters.
|
||||
*/
|
||||
export function buildTaskSpec(params: {
|
||||
agentId: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
type?: string;
|
||||
priority?: string;
|
||||
maxTokens?: number;
|
||||
maxCost?: number;
|
||||
timeoutMinutes?: number;
|
||||
canModifyParentFiles?: boolean;
|
||||
contextFiles?: string[];
|
||||
dependsOn?: string[];
|
||||
completionPhrase?: string;
|
||||
outputFormat?: string;
|
||||
successCriteria?: string;
|
||||
workingDir?: string;
|
||||
}): string {
|
||||
const lines: string[] = ['---'];
|
||||
|
||||
lines.push(`agentId: ${params.agentId}`);
|
||||
lines.push(`name: ${params.name}`);
|
||||
|
||||
if (params.type) lines.push(`type: ${params.type}`);
|
||||
if (params.priority) lines.push(`priority: ${params.priority}`);
|
||||
if (params.maxTokens != null) lines.push(`maxTokens: ${params.maxTokens}`);
|
||||
if (params.maxCost != null) lines.push(`maxCost: ${params.maxCost}`);
|
||||
if (params.timeoutMinutes != null) lines.push(`timeoutMinutes: ${params.timeoutMinutes}`);
|
||||
if (params.canModifyParentFiles != null) lines.push(`canModifyParentFiles: ${params.canModifyParentFiles}`);
|
||||
if (params.completionPhrase) lines.push(`completionPhrase: ${params.completionPhrase}`);
|
||||
if (params.outputFormat) lines.push(`outputFormat: ${params.outputFormat}`);
|
||||
if (params.successCriteria) lines.push(`successCriteria: "${params.successCriteria.replace(/"/g, '\\"')}"`);
|
||||
if (params.workingDir) lines.push(`workingDir: ${params.workingDir}`);
|
||||
|
||||
if (params.contextFiles && params.contextFiles.length > 0) {
|
||||
lines.push(`contextFiles: [${params.contextFiles.join(', ')}]`);
|
||||
}
|
||||
|
||||
if (params.dependsOn && params.dependsOn.length > 0) {
|
||||
lines.push(`dependsOn: [${params.dependsOn.join(', ')}]`);
|
||||
}
|
||||
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
lines.push(params.instructions);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ========== MCP Server Setup ==========
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'claudeman-spawn',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
// ---- spawn_agent ----
|
||||
|
||||
server.tool(
|
||||
'spawn_agent',
|
||||
'Spawn a new autonomous Claude agent to handle a subtask. The agent runs in its own session with full capabilities.',
|
||||
{
|
||||
agentId: z.string().describe('Unique identifier for the agent (e.g., "research-auth-001")'),
|
||||
name: z.string().describe('Human-readable name for the agent'),
|
||||
instructions: z.string().describe('Detailed task instructions for the agent (markdown)'),
|
||||
type: z.enum(['explore', 'implement', 'test', 'review', 'refactor', 'research', 'generate', 'fix', 'general']).optional().describe('Task type/category'),
|
||||
priority: z.enum(['low', 'normal', 'high', 'critical']).optional().describe('Priority for queue ordering'),
|
||||
maxTokens: z.number().optional().describe('Maximum token budget (input + output combined)'),
|
||||
maxCost: z.number().optional().describe('Maximum cost in USD'),
|
||||
timeoutMinutes: z.number().optional().describe('Maximum runtime in minutes (max: 120)'),
|
||||
canModifyParentFiles: z.boolean().optional().describe('Whether agent can modify files in parent project directory'),
|
||||
contextFiles: z.array(z.string()).optional().describe('Files to symlink into agent workspace as context'),
|
||||
dependsOn: z.array(z.string()).optional().describe('Agent IDs that must complete before this one starts'),
|
||||
completionPhrase: z.string().optional().describe('Phrase agent outputs when finished (default: auto-generated)'),
|
||||
outputFormat: z.enum(['markdown', 'json', 'code', 'structured', 'freeform']).optional().describe('Expected output format'),
|
||||
successCriteria: z.string().optional().describe('Success criteria included in agent instructions'),
|
||||
workingDir: z.string().optional().describe('Working directory (relative to parent, or absolute)'),
|
||||
},
|
||||
async (params) => {
|
||||
if (!SESSION_ID) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Error: CLAUDEMAN_SESSION_ID not set. This tool must be run within a Claudeman-managed session.' }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const taskSpec = buildTaskSpec(params);
|
||||
|
||||
try {
|
||||
const { status, data } = await apiRequest('POST', '/api/spawn/trigger', {
|
||||
parentSessionId: SESSION_ID,
|
||||
taskContent: taskSpec,
|
||||
});
|
||||
|
||||
if (status >= 400) {
|
||||
const errorData = data as { error?: { code?: string; details?: string } };
|
||||
const errorMsg = errorData.error?.details || errorData.error?.code || 'Unknown error';
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error spawning agent: ${errorMsg}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = data as { success: boolean; data?: { agentId: string } };
|
||||
const agentId = result.data?.agentId || params.agentId;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Agent spawned successfully.\n\nAgent ID: ${agentId}` }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}. Is the web server running?\n\n${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ---- list_agents ----
|
||||
|
||||
server.tool(
|
||||
'list_agents',
|
||||
'List all spawn agents (active, queued, and completed).',
|
||||
{},
|
||||
async () => {
|
||||
try {
|
||||
const { status, data } = await apiRequest('GET', '/api/spawn/agents');
|
||||
|
||||
if (status >= 400) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error listing agents: ${JSON.stringify(data)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const agents = data as Array<{ agentId: string; name: string; status: string; type: string; priority: string }>;
|
||||
if (agents.length === 0) {
|
||||
return { content: [{ type: 'text', text: 'No agents found.' }] };
|
||||
}
|
||||
|
||||
const summary = agents.map(a => `- ${a.agentId} (${a.name}): ${a.status} [${a.type}, ${a.priority}]`).join('\n');
|
||||
return { content: [{ type: 'text', text: `Agents (${agents.length}):\n\n${summary}` }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}.\n\n${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ---- get_agent_status ----
|
||||
|
||||
server.tool(
|
||||
'get_agent_status',
|
||||
'Get detailed status and progress of a specific agent.',
|
||||
{
|
||||
agentId: z.string().describe('The agent ID to query'),
|
||||
},
|
||||
async ({ agentId }) => {
|
||||
try {
|
||||
const { status, data } = await apiRequest('GET', `/api/spawn/agents/${encodeURIComponent(agentId)}`);
|
||||
|
||||
if (status === 404) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Agent not found: ${agentId}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (status >= 400) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error getting agent status: ${JSON.stringify(data)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}.\n\n${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ---- get_agent_result ----
|
||||
|
||||
server.tool(
|
||||
'get_agent_result',
|
||||
'Read the result of a completed agent. Returns the agent\'s output and metadata.',
|
||||
{
|
||||
agentId: z.string().describe('The agent ID whose result to read'),
|
||||
},
|
||||
async ({ agentId }) => {
|
||||
try {
|
||||
const { status, data } = await apiRequest('GET', `/api/spawn/agents/${encodeURIComponent(agentId)}/result`);
|
||||
|
||||
if (status === 404) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Agent or result not found: ${agentId}. The agent may not have completed yet.` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (status >= 400) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error getting agent result: ${JSON.stringify(data)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Result could be a string (raw markdown) or an object
|
||||
const resultText = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||
return { content: [{ type: 'text', text: resultText }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}.\n\n${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ---- send_agent_message ----
|
||||
|
||||
server.tool(
|
||||
'send_agent_message',
|
||||
'Send a message to a running agent. The message is written to the agent\'s communication channel.',
|
||||
{
|
||||
agentId: z.string().describe('The agent ID to message'),
|
||||
message: z.string().describe('The message content (markdown)'),
|
||||
},
|
||||
async ({ agentId, message }) => {
|
||||
try {
|
||||
const { status, data } = await apiRequest('POST', `/api/spawn/agents/${encodeURIComponent(agentId)}/message`, {
|
||||
content: message,
|
||||
sender: 'parent',
|
||||
});
|
||||
|
||||
if (status === 404) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Agent not found: ${agentId}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (status >= 400) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error sending message: ${JSON.stringify(data)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: `Message sent to agent ${agentId}.` }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}.\n\n${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ---- cancel_agent ----
|
||||
|
||||
server.tool(
|
||||
'cancel_agent',
|
||||
'Cancel a running agent. Sends a graceful shutdown signal.',
|
||||
{
|
||||
agentId: z.string().describe('The agent ID to cancel'),
|
||||
reason: z.string().optional().describe('Reason for cancellation'),
|
||||
},
|
||||
async ({ agentId, reason }) => {
|
||||
try {
|
||||
const { status, data } = await apiRequest('POST', `/api/spawn/agents/${encodeURIComponent(agentId)}/cancel`, {
|
||||
reason: reason || 'Cancelled by parent session',
|
||||
});
|
||||
|
||||
if (status === 404) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Agent not found: ${agentId}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (status >= 400) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error cancelling agent: ${JSON.stringify(data)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: `Agent ${agentId} cancel request sent.` }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}.\n\n${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ========== Start Server ==========
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('MCP server failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -168,7 +168,7 @@ export class ScreenManager extends EventEmitter {
|
||||
// Create screen in detached mode with the appropriate command
|
||||
// Set CLAUDEMAN_SCREEN=1 so Claude sessions know they're running in Claudeman
|
||||
// This helps prevent Claude from attempting to kill its own screen session
|
||||
const envVars = `CLAUDEMAN_SCREEN=1 CLAUDEMAN_SESSION_ID=${sessionId} CLAUDEMAN_SCREEN_NAME=${screenName}`;
|
||||
const envVars = `CLAUDEMAN_SCREEN=1 CLAUDEMAN_SESSION_ID=${sessionId} CLAUDEMAN_SCREEN_NAME=${screenName} CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`;
|
||||
const cmd = mode === 'claude'
|
||||
? `${envVars} claude --dangerously-skip-permissions`
|
||||
: `${envVars} $SHELL`;
|
||||
|
||||
+3
-47
@@ -21,7 +21,6 @@ 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';
|
||||
@@ -226,14 +225,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,9 +325,6 @@ 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[] = [];
|
||||
@@ -355,13 +343,6 @@ 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;
|
||||
@@ -405,18 +386,6 @@ export class Session extends EventEmitter {
|
||||
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 {
|
||||
@@ -504,11 +473,6 @@ 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;
|
||||
}
|
||||
@@ -785,6 +749,7 @@ export class Session extends EventEmitter {
|
||||
// Inform Claude it's running within Claudeman (helps prevent self-termination)
|
||||
CLAUDEMAN_SCREEN: '1',
|
||||
CLAUDEMAN_SESSION_ID: this.id,
|
||||
CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -809,9 +774,6 @@ 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);
|
||||
|
||||
@@ -958,6 +920,7 @@ export class Session extends EventEmitter {
|
||||
TERM: 'xterm-256color',
|
||||
CLAUDEMAN_SCREEN: '1',
|
||||
CLAUDEMAN_SESSION_ID: this.id,
|
||||
CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1067,6 +1030,7 @@ export class Session extends EventEmitter {
|
||||
// Inform Claude it's running within Claudeman
|
||||
CLAUDEMAN_SCREEN: '1',
|
||||
CLAUDEMAN_SESSION_ID: this.id,
|
||||
CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1460,14 +1424,6 @@ export class Session extends EventEmitter {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -212,7 +212,7 @@ export interface AgentStatusReport {
|
||||
dependencyStatus: 'waiting' | 'ready' | 'n/a';
|
||||
}
|
||||
|
||||
// ========== Tracker State (for SpawnDetector) ==========
|
||||
// ========== Tracker State (for SpawnOrchestrator) ==========
|
||||
|
||||
export interface SpawnTrackerState {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -357,90 +357,6 @@ Instead: Fix the issue, verify, then complete. For time-aware loops: generate mo
|
||||
|
||||
---
|
||||
|
||||
## Spawn1337: Autonomous Agent Spawning
|
||||
|
||||
You can spawn autonomous child agents to handle subtasks in parallel. Each agent runs in its own Claude session with full capabilities.
|
||||
|
||||
### How to Spawn an Agent
|
||||
|
||||
1. **Write a task spec file** (anywhere in your working directory):
|
||||
|
||||
```markdown
|
||||
---
|
||||
agentId: my-agent-001
|
||||
name: My Research Agent
|
||||
type: explore
|
||||
priority: normal
|
||||
maxTokens: 150000
|
||||
maxCost: 0.50
|
||||
timeoutMinutes: 15
|
||||
canModifyParentFiles: false
|
||||
contextFiles: [src/auth.ts, src/types.ts]
|
||||
completionPhrase: RESEARCH_DONE
|
||||
outputFormat: structured
|
||||
---
|
||||
|
||||
Your task instructions here. Be specific about what you want the agent to do
|
||||
and what output format you expect.
|
||||
```
|
||||
|
||||
2. **Output the spawn tag** to trigger the orchestrator:
|
||||
|
||||
```
|
||||
<spawn1337>path/to/task-spec.md</spawn1337>
|
||||
```
|
||||
|
||||
The orchestrator will read the file, create the agent's workspace, and start a new Claude session.
|
||||
|
||||
### Task Spec Fields
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `agentId` | Yes | - | Unique identifier for the agent |
|
||||
| `name` | Yes | - | Human-readable name |
|
||||
| `type` | No | `general` | `explore\|implement\|test\|review\|refactor\|research\|generate\|fix\|general` |
|
||||
| `priority` | No | `normal` | `low\|normal\|high\|critical` |
|
||||
| `maxTokens` | No | 150000 | Token budget |
|
||||
| `maxCost` | No | 0.50 | Cost budget in USD |
|
||||
| `timeoutMinutes` | No | 30 | Max runtime (max: 120) |
|
||||
| `canModifyParentFiles` | No | `false` | Whether agent can edit parent project files |
|
||||
| `contextFiles` | No | `[]` | Files to symlink into agent workspace |
|
||||
| `dependsOn` | No | `[]` | Agent IDs that must complete first |
|
||||
| `completionPhrase` | No | `AGENT_DONE` | Phrase agent outputs when finished |
|
||||
| `outputFormat` | No | `structured` | `markdown\|json\|code\|structured\|freeform` |
|
||||
|
||||
### Monitoring and Communication
|
||||
|
||||
```
|
||||
<spawn1337-status agentId="my-agent-001"/>
|
||||
```
|
||||
Query an agent's current status and progress.
|
||||
|
||||
```
|
||||
<spawn1337-message agentId="my-agent-001">
|
||||
Please also check the error handling in auth.ts
|
||||
</spawn1337-message>
|
||||
```
|
||||
Send a message to a running agent (written to its comms directory).
|
||||
|
||||
```
|
||||
<spawn1337-cancel agentId="my-agent-001"/>
|
||||
```
|
||||
Cancel a running agent (graceful shutdown).
|
||||
|
||||
### Resource Limits
|
||||
|
||||
- Max concurrent agents: 5
|
||||
- Max spawn depth: 3 (agents can spawn children)
|
||||
- Budget warning at 80%, shutdown at 100%, force kill at 110%
|
||||
- Default timeout: 30 min, max: 120 min
|
||||
|
||||
### Agent Results
|
||||
|
||||
When an agent completes, its result is written to `spawn-comms/result.md` in its workspace. The orchestrator notifies you via SSE events. Results include YAML frontmatter with status, summary, and files changed.
|
||||
|
||||
---
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Before Writing
|
||||
|
||||
+26
-28
@@ -1036,6 +1036,9 @@ export class WebServer extends EventEmitter {
|
||||
const claudeMd = generateClaudeMd(name, description || '', templatePath);
|
||||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
||||
|
||||
// Write .mcp.json for Claude Code to discover spawn tools
|
||||
this.writeMcpConfig(casePath);
|
||||
|
||||
this.broadcast('case:created', { name, path: casePath });
|
||||
|
||||
return { success: true, case: { name, path: casePath } };
|
||||
@@ -1173,6 +1176,9 @@ export class WebServer extends EventEmitter {
|
||||
const claudeMd = generateClaudeMd(caseName, '', templatePath);
|
||||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
||||
|
||||
// Write .mcp.json for Claude Code to discover spawn tools
|
||||
this.writeMcpConfig(casePath);
|
||||
|
||||
this.broadcast('case:created', { name: caseName, path: casePath });
|
||||
} catch (err) {
|
||||
return { success: false, error: `Failed to create case: ${getErrorMessage(err)}` };
|
||||
@@ -1636,34 +1642,6 @@ export class WebServer extends EventEmitter {
|
||||
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 {
|
||||
@@ -1829,6 +1807,23 @@ export class WebServer extends EventEmitter {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write .mcp.json to a case directory for Claude Code to discover spawn MCP tools.
|
||||
*/
|
||||
private writeMcpConfig(casePath: string): void {
|
||||
const projectRoot = join(__dirname, '..', '..');
|
||||
const mcpServerPath = join(projectRoot, 'dist', 'mcp-server.js');
|
||||
const mcpConfig = {
|
||||
mcpServers: {
|
||||
'claudeman-spawn': {
|
||||
command: 'node',
|
||||
args: [mcpServerPath],
|
||||
},
|
||||
},
|
||||
};
|
||||
writeFileSync(join(casePath, '.mcp.json'), JSON.stringify(mcpConfig, null, 2) + '\n');
|
||||
}
|
||||
|
||||
private async startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise<ScheduledRun> {
|
||||
const id = uuidv4();
|
||||
const now = Date.now();
|
||||
@@ -2164,6 +2159,9 @@ export class WebServer extends EventEmitter {
|
||||
await this.app.listen({ port: this.port, host: '0.0.0.0' });
|
||||
console.log(`Claudeman web interface running at http://localhost:${this.port}`);
|
||||
|
||||
// Set API URL for child processes (MCP server, spawned sessions)
|
||||
process.env.CLAUDEMAN_API_URL = `http://localhost:${this.port}`;
|
||||
|
||||
// Start scheduled runs cleanup timer
|
||||
this.scheduledCleanupTimer = setInterval(() => {
|
||||
this.cleanupScheduledRuns();
|
||||
|
||||
Reference in New Issue
Block a user