mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16: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",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
"claudeman": "./dist/index.js"
|
"claudeman": "./dist/index.js",
|
||||||
|
"claudeman-mcp": "./dist/mcp-server.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"web": "node dist/index.js web",
|
"web": "node dist/index.js web",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/static": "^8.0.0",
|
"@fastify/static": "^8.0.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.25.3",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^5.1.0",
|
"fastify": "^5.1.0",
|
||||||
@@ -44,7 +46,8 @@
|
|||||||
"ink-text-input": "^6.0.0",
|
"ink-text-input": "^6.0.0",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.14.0",
|
"@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
|
// Create screen in detached mode with the appropriate command
|
||||||
// Set CLAUDEMAN_SCREEN=1 so Claude sessions know they're running in Claudeman
|
// 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
|
// 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'
|
const cmd = mode === 'claude'
|
||||||
? `${envVars} claude --dangerously-skip-permissions`
|
? `${envVars} claude --dangerously-skip-permissions`
|
||||||
: `${envVars} $SHELL`;
|
: `${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 { SessionState, SessionStatus, SessionConfig, ScreenSession, RalphTrackerState, RalphTodoItem } from './types.js';
|
||||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||||
import { RalphTracker } from './ralph-tracker.js';
|
import { RalphTracker } from './ralph-tracker.js';
|
||||||
import { SpawnDetector } from './spawn-detector.js';
|
|
||||||
import { ScreenManager } from './screen-manager.js';
|
import { ScreenManager } from './screen-manager.js';
|
||||||
|
|
||||||
export type { BackgroundTask } from './task-tracker.js';
|
export type { BackgroundTask } from './task-tracker.js';
|
||||||
@@ -226,14 +225,6 @@ export interface SessionEvents {
|
|||||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
||||||
/** Ralph completion phrase detected */
|
/** Ralph completion phrase detected */
|
||||||
ralphCompletionDetected: (phrase: string) => void;
|
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)
|
// Ralph tracking (Ralph Wiggum loops and todo lists inside Claude Code)
|
||||||
private _ralphTracker: RalphTracker;
|
private _ralphTracker: RalphTracker;
|
||||||
|
|
||||||
// Spawn1337 detection (agent spawning protocol)
|
|
||||||
private _spawnDetector: SpawnDetector;
|
|
||||||
|
|
||||||
// Agent tree tracking
|
// Agent tree tracking
|
||||||
private _parentAgentId: string | null = null;
|
private _parentAgentId: string | null = null;
|
||||||
private _childAgentIds: string[] = [];
|
private _childAgentIds: string[] = [];
|
||||||
@@ -355,13 +343,6 @@ export class Session extends EventEmitter {
|
|||||||
completionDetected: (phrase: string) => void;
|
completionDetected: (phrase: string) => void;
|
||||||
} | null = null;
|
} | 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> & {
|
constructor(config: Partial<SessionConfig> & {
|
||||||
workingDir: string;
|
workingDir: string;
|
||||||
mode?: SessionMode;
|
mode?: SessionMode;
|
||||||
@@ -405,18 +386,6 @@ export class Session extends EventEmitter {
|
|||||||
this._ralphTracker.on('todoUpdate', this._ralphHandlers.todoUpdate);
|
this._ralphTracker.on('todoUpdate', this._ralphHandlers.todoUpdate);
|
||||||
this._ralphTracker.on('completionDetected', this._ralphHandlers.completionDetected);
|
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 {
|
get status(): SessionStatus {
|
||||||
@@ -504,11 +473,6 @@ export class Session extends EventEmitter {
|
|||||||
return this._ralphTracker.getTodoStats();
|
return this._ralphTracker.getTodoStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn1337 tracking getters
|
|
||||||
get spawnDetector(): SpawnDetector {
|
|
||||||
return this._spawnDetector;
|
|
||||||
}
|
|
||||||
|
|
||||||
get parentAgentId(): string | null {
|
get parentAgentId(): string | null {
|
||||||
return this._parentAgentId;
|
return this._parentAgentId;
|
||||||
}
|
}
|
||||||
@@ -785,6 +749,7 @@ export class Session extends EventEmitter {
|
|||||||
// Inform Claude it's running within Claudeman (helps prevent self-termination)
|
// Inform Claude it's running within Claudeman (helps prevent self-termination)
|
||||||
CLAUDEMAN_SCREEN: '1',
|
CLAUDEMAN_SCREEN: '1',
|
||||||
CLAUDEMAN_SESSION_ID: this.id,
|
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
|
// Forward to Ralph tracker to detect Ralph loops and todos
|
||||||
this._ralphTracker.processTerminalData(data);
|
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")
|
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
|
||||||
this.parseTokensFromStatusLine(data);
|
this.parseTokensFromStatusLine(data);
|
||||||
|
|
||||||
@@ -958,6 +920,7 @@ export class Session extends EventEmitter {
|
|||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
CLAUDEMAN_SCREEN: '1',
|
CLAUDEMAN_SCREEN: '1',
|
||||||
CLAUDEMAN_SESSION_ID: this.id,
|
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
|
// Inform Claude it's running within Claudeman
|
||||||
CLAUDEMAN_SCREEN: '1',
|
CLAUDEMAN_SCREEN: '1',
|
||||||
CLAUDEMAN_SESSION_ID: this.id,
|
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;
|
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';
|
dependencyStatus: 'waiting' | 'ready' | 'n/a';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Tracker State (for SpawnDetector) ==========
|
// ========== Tracker State (for SpawnOrchestrator) ==========
|
||||||
|
|
||||||
export interface SpawnTrackerState {
|
export interface SpawnTrackerState {
|
||||||
enabled: boolean;
|
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
|
## Code Standards
|
||||||
|
|
||||||
### Before Writing
|
### Before Writing
|
||||||
|
|||||||
+26
-28
@@ -1036,6 +1036,9 @@ export class WebServer extends EventEmitter {
|
|||||||
const claudeMd = generateClaudeMd(name, description || '', templatePath);
|
const claudeMd = generateClaudeMd(name, description || '', templatePath);
|
||||||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
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 });
|
this.broadcast('case:created', { name, path: casePath });
|
||||||
|
|
||||||
return { success: true, case: { name, path: casePath } };
|
return { success: true, case: { name, path: casePath } };
|
||||||
@@ -1173,6 +1176,9 @@ export class WebServer extends EventEmitter {
|
|||||||
const claudeMd = generateClaudeMd(caseName, '', templatePath);
|
const claudeMd = generateClaudeMd(caseName, '', templatePath);
|
||||||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
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 });
|
this.broadcast('case:created', { name: caseName, path: casePath });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: `Failed to create case: ${getErrorMessage(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 });
|
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 {
|
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
|
||||||
@@ -1829,6 +1807,23 @@ export class WebServer extends EventEmitter {
|
|||||||
return undefined;
|
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> {
|
private async startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise<ScheduledRun> {
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const now = Date.now();
|
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' });
|
await this.app.listen({ port: this.port, host: '0.0.0.0' });
|
||||||
console.log(`Claudeman web interface running at http://localhost:${this.port}`);
|
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
|
// Start scheduled runs cleanup timer
|
||||||
this.scheduledCleanupTimer = setInterval(() => {
|
this.scheduledCleanupTimer = setInterval(() => {
|
||||||
this.cleanupScheduledRuns();
|
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