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:
arkon
2026-01-23 23:16:21 +01:00
co-authored by Claude Opus 4.5
parent 7c72802886
commit 99919d6dfb
11 changed files with 1549 additions and 710 deletions
+918 -5
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -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",
+370
View File
@@ -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);
});
+1 -1
View File
@@ -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
View File
@@ -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;
}
}
/**
-292
View File
@@ -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
View File
@@ -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;
-84
View File
@@ -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
View File
@@ -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();
+224
View File
@@ -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');
});
});
});
-249
View File
@@ -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));
});
});
});