mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +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:
Generated
+918
-5
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -5,10 +5,11 @@
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"claudeman": "./dist/index.js"
|
||||
"claudeman": "./dist/index.js",
|
||||
"claudeman-mcp": "./dist/mcp-server.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && chmod +x dist/index.js && mkdir -p dist/web dist/templates && cp -r src/web/public dist/web/ && cp src/templates/case-template.md dist/templates/",
|
||||
"build": "tsc && chmod +x dist/index.js dist/mcp-server.js && mkdir -p dist/web dist/templates && cp -r src/web/public dist/web/ && cp src/templates/case-template.md dist/templates/",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts",
|
||||
"web": "node dist/index.js web",
|
||||
@@ -36,6 +37,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.3",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^5.1.0",
|
||||
@@ -44,7 +46,8 @@
|
||||
"ink-text-input": "^6.0.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"react": "^19.2.3",
|
||||
"uuid": "^10.0.0"
|
||||
"uuid": "^10.0.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildTaskSpec } from '../src/mcp-server.js';
|
||||
|
||||
describe('mcp-server', () => {
|
||||
describe('buildTaskSpec', () => {
|
||||
it('should build minimal task spec with required fields only', () => {
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'test-001',
|
||||
name: 'Test Agent',
|
||||
instructions: 'Do something useful.',
|
||||
});
|
||||
|
||||
expect(result).toContain('---');
|
||||
expect(result).toContain('agentId: test-001');
|
||||
expect(result).toContain('name: Test Agent');
|
||||
expect(result).toContain('Do something useful.');
|
||||
|
||||
// Should not contain optional fields
|
||||
expect(result).not.toContain('type:');
|
||||
expect(result).not.toContain('priority:');
|
||||
expect(result).not.toContain('maxTokens:');
|
||||
expect(result).not.toContain('maxCost:');
|
||||
expect(result).not.toContain('timeoutMinutes:');
|
||||
expect(result).not.toContain('contextFiles:');
|
||||
expect(result).not.toContain('dependsOn:');
|
||||
});
|
||||
|
||||
it('should include all optional fields when provided', () => {
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'full-agent',
|
||||
name: 'Full Agent',
|
||||
instructions: 'Complete task.',
|
||||
type: 'implement',
|
||||
priority: 'high',
|
||||
maxTokens: 200000,
|
||||
maxCost: 1.50,
|
||||
timeoutMinutes: 60,
|
||||
canModifyParentFiles: true,
|
||||
contextFiles: ['src/auth.ts', 'src/types.ts'],
|
||||
dependsOn: ['agent-a', 'agent-b'],
|
||||
completionPhrase: 'FULL_DONE',
|
||||
outputFormat: 'structured',
|
||||
successCriteria: 'All tests pass',
|
||||
workingDir: '/tmp/workspace',
|
||||
});
|
||||
|
||||
expect(result).toContain('agentId: full-agent');
|
||||
expect(result).toContain('name: Full Agent');
|
||||
expect(result).toContain('type: implement');
|
||||
expect(result).toContain('priority: high');
|
||||
expect(result).toContain('maxTokens: 200000');
|
||||
expect(result).toContain('maxCost: 1.5');
|
||||
expect(result).toContain('timeoutMinutes: 60');
|
||||
expect(result).toContain('canModifyParentFiles: true');
|
||||
expect(result).toContain('contextFiles: [src/auth.ts, src/types.ts]');
|
||||
expect(result).toContain('dependsOn: [agent-a, agent-b]');
|
||||
expect(result).toContain('completionPhrase: FULL_DONE');
|
||||
expect(result).toContain('outputFormat: structured');
|
||||
expect(result).toContain('successCriteria: "All tests pass"');
|
||||
expect(result).toContain('workingDir: /tmp/workspace');
|
||||
expect(result).toContain('Complete task.');
|
||||
});
|
||||
|
||||
it('should produce valid YAML frontmatter structure', () => {
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'yaml-test',
|
||||
name: 'YAML Test',
|
||||
instructions: 'Body content here.',
|
||||
});
|
||||
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toBe('---');
|
||||
|
||||
// Find closing ---
|
||||
const closingIndex = lines.indexOf('---', 1);
|
||||
expect(closingIndex).toBeGreaterThan(0);
|
||||
|
||||
// Body should come after closing ---
|
||||
const body = lines.slice(closingIndex + 1).join('\n').trim();
|
||||
expect(body).toBe('Body content here.');
|
||||
});
|
||||
|
||||
it('should escape double quotes in successCriteria', () => {
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'escape-test',
|
||||
name: 'Escape Test',
|
||||
instructions: 'test',
|
||||
successCriteria: 'Output "hello" correctly',
|
||||
});
|
||||
|
||||
expect(result).toContain('successCriteria: "Output \\"hello\\" correctly"');
|
||||
});
|
||||
|
||||
it('should handle empty context files array', () => {
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'empty-ctx',
|
||||
name: 'Empty Context',
|
||||
instructions: 'test',
|
||||
contextFiles: [],
|
||||
});
|
||||
|
||||
expect(result).not.toContain('contextFiles:');
|
||||
});
|
||||
|
||||
it('should handle empty dependsOn array', () => {
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'empty-deps',
|
||||
name: 'Empty Deps',
|
||||
instructions: 'test',
|
||||
dependsOn: [],
|
||||
});
|
||||
|
||||
expect(result).not.toContain('dependsOn:');
|
||||
});
|
||||
|
||||
it('should handle canModifyParentFiles: false', () => {
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'no-modify',
|
||||
name: 'No Modify',
|
||||
instructions: 'test',
|
||||
canModifyParentFiles: false,
|
||||
});
|
||||
|
||||
expect(result).toContain('canModifyParentFiles: false');
|
||||
});
|
||||
|
||||
it('should handle multiline instructions', () => {
|
||||
const instructions = `# Step 1
|
||||
Do this first.
|
||||
|
||||
# Step 2
|
||||
Then do this.
|
||||
|
||||
## Notes
|
||||
- Important detail
|
||||
- Another detail`;
|
||||
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'multiline',
|
||||
name: 'Multiline',
|
||||
instructions,
|
||||
});
|
||||
|
||||
expect(result).toContain('# Step 1');
|
||||
expect(result).toContain('Do this first.');
|
||||
expect(result).toContain('# Step 2');
|
||||
expect(result).toContain('- Important detail');
|
||||
});
|
||||
|
||||
it('should be parseable by parseYamlFrontmatter', async () => {
|
||||
const { parseYamlFrontmatter } = await import('../src/spawn-types.js');
|
||||
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'parse-test',
|
||||
name: 'Parse Test',
|
||||
instructions: 'Verify parsing works.',
|
||||
type: 'explore',
|
||||
priority: 'high',
|
||||
maxTokens: 100000,
|
||||
timeoutMinutes: 15,
|
||||
});
|
||||
|
||||
const parsed = parseYamlFrontmatter(result);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.frontmatter.agentId).toBe('parse-test');
|
||||
expect(parsed!.frontmatter.name).toBe('Parse Test');
|
||||
expect(parsed!.frontmatter.type).toBe('explore');
|
||||
expect(parsed!.frontmatter.priority).toBe('high');
|
||||
expect(parsed!.frontmatter.maxTokens).toBe(100000);
|
||||
expect(parsed!.frontmatter.timeoutMinutes).toBe(15);
|
||||
expect(parsed!.body).toBe('Verify parsing works.');
|
||||
});
|
||||
|
||||
it('should produce spec parseable by parseTaskSpecFile', async () => {
|
||||
const { parseTaskSpecFile } = await import('../src/spawn-types.js');
|
||||
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'full-spec',
|
||||
name: 'Full Spec Agent',
|
||||
instructions: 'Do the work.',
|
||||
type: 'implement',
|
||||
priority: 'critical',
|
||||
maxTokens: 250000,
|
||||
maxCost: 2.0,
|
||||
timeoutMinutes: 45,
|
||||
canModifyParentFiles: true,
|
||||
completionPhrase: 'SPEC_DONE',
|
||||
outputFormat: 'json',
|
||||
});
|
||||
|
||||
const parsed = parseTaskSpecFile(result, 'fallback-id');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.spec.agentId).toBe('full-spec');
|
||||
expect(parsed!.spec.name).toBe('Full Spec Agent');
|
||||
expect(parsed!.spec.type).toBe('implement');
|
||||
expect(parsed!.spec.priority).toBe('critical');
|
||||
expect(parsed!.spec.maxTokens).toBe(250000);
|
||||
expect(parsed!.spec.maxCost).toBe(2.0);
|
||||
expect(parsed!.spec.timeoutMinutes).toBe(45);
|
||||
expect(parsed!.spec.canModifyParentFiles).toBe(true);
|
||||
expect(parsed!.spec.completionPhrase).toBe('SPEC_DONE');
|
||||
expect(parsed!.spec.outputFormat).toBe('json');
|
||||
expect(parsed!.instructions).toBe('Do the work.');
|
||||
});
|
||||
|
||||
it('should handle contextFiles in parseable format', async () => {
|
||||
const { parseYamlFrontmatter } = await import('../src/spawn-types.js');
|
||||
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'ctx-parse',
|
||||
name: 'Context Parse',
|
||||
instructions: 'test',
|
||||
contextFiles: ['src/foo.ts', 'src/bar.ts'],
|
||||
});
|
||||
|
||||
const parsed = parseYamlFrontmatter(result);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(Array.isArray(parsed!.frontmatter.contextFiles)).toBe(true);
|
||||
const files = parsed!.frontmatter.contextFiles as string[];
|
||||
expect(files).toContain('src/foo.ts');
|
||||
expect(files).toContain('src/bar.ts');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { SpawnDetector } from '../src/spawn-detector.js';
|
||||
|
||||
describe('SpawnDetector', () => {
|
||||
let detector: SpawnDetector;
|
||||
|
||||
beforeEach(() => {
|
||||
detector = new SpawnDetector();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should start disabled', () => {
|
||||
expect(detector.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should start with initial state', () => {
|
||||
const state = detector.state;
|
||||
expect(state.enabled).toBe(false);
|
||||
expect(state.activeCount).toBe(0);
|
||||
expect(state.totalSpawned).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-Enable', () => {
|
||||
it('should auto-enable on spawn tag detection', () => {
|
||||
detector.processTerminalData('<spawn1337>task.md</spawn1337>\n');
|
||||
expect(detector.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should not enable on unrelated data', () => {
|
||||
detector.processTerminalData('Hello world\nsome output\n');
|
||||
expect(detector.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should auto-enable on status tag', () => {
|
||||
detector.processTerminalData('<spawn1337-status agentId="test-001"/>\n');
|
||||
expect(detector.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should auto-enable on cancel tag', () => {
|
||||
detector.processTerminalData('<spawn1337-cancel agentId="test-001"/>\n');
|
||||
expect(detector.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spawn Request Detection', () => {
|
||||
it('should detect spawn tag and emit event', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
detector.processTerminalData('<spawn1337>tasks/auth-explore.md</spawn1337>\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('tasks/auth-explore.md', expect.any(String));
|
||||
});
|
||||
|
||||
it('should detect spawn tag with path containing slashes', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
detector.processTerminalData('<spawn1337>src/tasks/deep/nested.md</spawn1337>\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('src/tasks/deep/nested.md', expect.any(String));
|
||||
});
|
||||
|
||||
it('should handle spawn tag with surrounding text', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
detector.processTerminalData('Starting agent: <spawn1337>task.md</spawn1337> done\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('task.md', expect.any(String));
|
||||
});
|
||||
|
||||
it('should increment totalSpawned count', () => {
|
||||
detector.processTerminalData('<spawn1337>task1.md</spawn1337>\n');
|
||||
detector.processTerminalData('<spawn1337>task2.md</spawn1337>\n');
|
||||
detector.flushPendingEvents();
|
||||
|
||||
expect(detector.state.totalSpawned).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle multiple spawn tags in one chunk', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
detector.processTerminalData(
|
||||
'<spawn1337>task1.md</spawn1337>\n<spawn1337>task2.md</spawn1337>\n'
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should strip ANSI codes before parsing', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
detector.processTerminalData('\x1b[32m<spawn1337>task.md</spawn1337>\x1b[0m\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('task.md', expect.any(String));
|
||||
});
|
||||
|
||||
it('should trim whitespace from file path', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
detector.processTerminalData('<spawn1337> task.md </spawn1337>\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('task.md', expect.any(String));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Request Detection', () => {
|
||||
it('should detect status query and emit event', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('statusRequested', handler);
|
||||
|
||||
detector.processTerminalData('<spawn1337-status agentId="auth-001"/>\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('auth-001');
|
||||
});
|
||||
|
||||
it('should handle status with complex agent ID', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('statusRequested', handler);
|
||||
|
||||
detector.processTerminalData('<spawn1337-status agentId="my-complex-agent-123"/>\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('my-complex-agent-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cancel Request Detection', () => {
|
||||
it('should detect cancel request and emit event', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('cancelRequested', handler);
|
||||
|
||||
detector.processTerminalData('<spawn1337-cancel agentId="test-agent"/>\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('test-agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Detection', () => {
|
||||
it('should detect single-line message', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('messageToChild', handler);
|
||||
|
||||
detector.processTerminalData(
|
||||
'<spawn1337-message agentId="agent-001">Focus on the JWT flow</spawn1337-message>\n'
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('agent-001', 'Focus on the JWT flow');
|
||||
});
|
||||
|
||||
it('should detect multi-line message via checkMultiLinePatterns', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('messageToChild', handler);
|
||||
|
||||
const multiline = '<spawn1337-message agentId="agent-001">Line 1\nLine 2\nLine 3</spawn1337-message>';
|
||||
detector.processTerminalData(multiline + '\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('agent-001', 'Line 1\nLine 2\nLine 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enable/Disable', () => {
|
||||
it('should emit stateUpdate when enabled', async () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('stateUpdate', handler);
|
||||
|
||||
detector.enable();
|
||||
detector.flushPendingEvents();
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(handler.mock.calls[0][0].enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should emit stateUpdate when disabled', () => {
|
||||
detector.enable();
|
||||
detector.flushPendingEvents();
|
||||
|
||||
const handler = vi.fn();
|
||||
detector.on('stateUpdate', handler);
|
||||
|
||||
detector.disable();
|
||||
detector.flushPendingEvents();
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(handler.mock.calls[0][0].enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reset', () => {
|
||||
it('should reset all state', () => {
|
||||
detector.processTerminalData('<spawn1337>task.md</spawn1337>\n');
|
||||
detector.flushPendingEvents();
|
||||
expect(detector.enabled).toBe(true);
|
||||
|
||||
detector.reset();
|
||||
|
||||
expect(detector.enabled).toBe(false);
|
||||
expect(detector.state.totalSpawned).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Update', () => {
|
||||
it('should allow external state updates', () => {
|
||||
detector.updateState({ activeCount: 3, queuedCount: 2 });
|
||||
detector.flushPendingEvents();
|
||||
|
||||
expect(detector.state.activeCount).toBe(3);
|
||||
expect(detector.state.queuedCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Line Buffer', () => {
|
||||
it('should handle data split across chunks', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
// Send in two chunks (split in the middle of the tag)
|
||||
detector.processTerminalData('<spawn13');
|
||||
detector.processTerminalData('37>task.md</spawn1337>\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('task.md', expect.any(String));
|
||||
});
|
||||
|
||||
it('should handle partial lines without emitting', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
// No newline - data stays in buffer
|
||||
detector.processTerminalData('<spawn1337>task.md</spawn1337>');
|
||||
|
||||
// Should not emit until newline
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit on subsequent newline', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
detector.processTerminalData('<spawn1337>task.md</spawn1337>');
|
||||
detector.processTerminalData('\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('task.md', expect.any(String));
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user