diff --git a/src/agent/manager.ts b/src/agent/manager.ts index fcbe7a7..a8c43f4 100644 --- a/src/agent/manager.ts +++ b/src/agent/manager.ts @@ -26,7 +26,14 @@ import type { AgentResumedEvent, AgentWaitingEvent, } from '../events/index.js'; -import { agentOutputSchema, agentOutputJsonSchema } from './schema.js'; +import { + agentOutputSchema, + agentOutputJsonSchema, + discussOutputSchema, + discussOutputJsonSchema, + breakdownOutputSchema, + breakdownOutputJsonSchema, +} from './schema.js'; /** * Result structure from Claude CLI with --output-format json @@ -66,12 +73,28 @@ export class ClaudeAgentManager implements AgentManager { private eventBus?: EventBus ) {} + /** + * Get the appropriate JSON schema for a given agent mode. + * Each mode has its own output schema for Claude CLI --json-schema flag. + */ + private getJsonSchemaForMode(mode: AgentMode): object { + switch (mode) { + case 'discuss': + return discussOutputJsonSchema; + case 'breakdown': + return breakdownOutputJsonSchema; + case 'execute': + default: + return agentOutputJsonSchema; + } + } + /** * Spawn a new agent to work on a task. * Creates isolated worktree, starts Claude CLI, persists state. */ async spawn(options: SpawnAgentOptions): Promise { - const { name, taskId, prompt, cwd } = options; + const { name, taskId, prompt, cwd, mode = 'execute' } = options; const worktreeId = randomUUID(); const branchName = `agent/${name}`; @@ -91,12 +114,14 @@ export class ClaudeAgentManager implements AgentManager { sessionId: null, worktreeId: worktree.id, status: 'running', + mode, }); // Use agent.id from repository for all tracking const agentId = agent.id; - // 3. Start Claude CLI in background with JSON schema for structured output + // 3. Start Claude CLI in background with mode-specific JSON schema + const jsonSchema = this.getJsonSchemaForMode(mode); const subprocess = execa( 'claude', [ @@ -105,7 +130,7 @@ export class ClaudeAgentManager implements AgentManager { '--output-format', 'json', '--json-schema', - JSON.stringify(agentOutputJsonSchema), + JSON.stringify(jsonSchema), ], { cwd: cwd ?? worktree.path, @@ -139,7 +164,7 @@ export class ClaudeAgentManager implements AgentManager { /** * Handle agent subprocess completion. - * Parses structured JSON result with discriminated union, updates session ID, emits events. + * Parses structured JSON result with mode-specific schema, updates session ID, emits events. */ private async handleAgentCompletion( agentId: string, @@ -158,95 +183,296 @@ export class ClaudeAgentManager implements AgentManager { await this.repository.updateSessionId(agentId, cliResult.session_id); } - // Parse the agent's structured output from result field - const agentOutput = agentOutputSchema.parse(JSON.parse(cliResult.result)); + const rawOutput = JSON.parse(cliResult.result); const active = this.activeAgents.get(agentId); - switch (agentOutput.status) { - case 'done': { - // Success path - if (active) { - active.result = { - success: true, - message: agentOutput.result, - filesModified: agentOutput.filesModified, - }; - } - await this.repository.updateStatus(agentId, 'idle'); - - if (this.eventBus) { - const event: AgentStoppedEvent = { - type: 'agent:stopped', - timestamp: new Date(), - payload: { - agentId, - name: agent.name, - taskId: agent.taskId ?? '', - reason: 'task_complete', - }, - }; - this.eventBus.emit(event); - } + // Parse output based on agent mode + switch (agent.mode) { + case 'discuss': + await this.handleDiscussOutput(agent, rawOutput, cliResult.session_id); break; - } - - case 'questions': { - // Questions path - agent needs input (one or more questions) - if (active) { - active.pendingQuestions = { - questions: agentOutput.questions, - }; - } - await this.repository.updateStatus(agentId, 'waiting_for_input'); - - if (this.eventBus) { - const event: AgentWaitingEvent = { - type: 'agent:waiting', - timestamp: new Date(), - payload: { - agentId, - name: agent.name, - taskId: agent.taskId ?? '', - sessionId: cliResult.session_id ?? agent.sessionId ?? '', - questions: agentOutput.questions, - }, - }; - this.eventBus.emit(event); - } + case 'breakdown': + await this.handleBreakdownOutput(agent, rawOutput); break; - } - - case 'unrecoverable_error': { - // Error path - if (active) { - active.result = { - success: false, - message: agentOutput.error, - }; - } - await this.repository.updateStatus(agentId, 'crashed'); - - if (this.eventBus) { - const event: AgentCrashedEvent = { - type: 'agent:crashed', - timestamp: new Date(), - payload: { - agentId, - name: agent.name, - taskId: agent.taskId ?? '', - error: agentOutput.error, - }, - }; - this.eventBus.emit(event); - } + case 'execute': + default: + await this.handleExecuteOutput(agent, rawOutput, cliResult.session_id); break; - } } } catch (error) { await this.handleAgentError(agentId, error); } } + /** + * Handle output for execute mode (default mode). + */ + private async handleExecuteOutput( + agent: { id: string; name: string; taskId: string | null; sessionId: string | null }, + rawOutput: unknown, + sessionId: string | undefined + ): Promise { + const agentOutput = agentOutputSchema.parse(rawOutput); + const active = this.activeAgents.get(agent.id); + + switch (agentOutput.status) { + case 'done': { + if (active) { + active.result = { + success: true, + message: agentOutput.result, + filesModified: agentOutput.filesModified, + }; + } + await this.repository.updateStatus(agent.id, 'idle'); + + if (this.eventBus) { + const event: AgentStoppedEvent = { + type: 'agent:stopped', + timestamp: new Date(), + payload: { + agentId: agent.id, + name: agent.name, + taskId: agent.taskId ?? '', + reason: 'task_complete', + }, + }; + this.eventBus.emit(event); + } + break; + } + + case 'questions': { + if (active) { + active.pendingQuestions = { + questions: agentOutput.questions, + }; + } + await this.repository.updateStatus(agent.id, 'waiting_for_input'); + + if (this.eventBus) { + const event: AgentWaitingEvent = { + type: 'agent:waiting', + timestamp: new Date(), + payload: { + agentId: agent.id, + name: agent.name, + taskId: agent.taskId ?? '', + sessionId: sessionId ?? agent.sessionId ?? '', + questions: agentOutput.questions, + }, + }; + this.eventBus.emit(event); + } + break; + } + + case 'unrecoverable_error': { + if (active) { + active.result = { + success: false, + message: agentOutput.error, + }; + } + await this.repository.updateStatus(agent.id, 'crashed'); + + if (this.eventBus) { + const event: AgentCrashedEvent = { + type: 'agent:crashed', + timestamp: new Date(), + payload: { + agentId: agent.id, + name: agent.name, + taskId: agent.taskId ?? '', + error: agentOutput.error, + }, + }; + this.eventBus.emit(event); + } + break; + } + } + } + + /** + * Handle output for discuss mode. + * Outputs decisions array when context gathering is complete. + */ + private async handleDiscussOutput( + agent: { id: string; name: string; taskId: string | null; sessionId: string | null }, + rawOutput: unknown, + sessionId: string | undefined + ): Promise { + const discussOutput = discussOutputSchema.parse(rawOutput); + const active = this.activeAgents.get(agent.id); + + switch (discussOutput.status) { + case 'context_complete': { + if (active) { + active.result = { + success: true, + message: discussOutput.summary, + }; + } + await this.repository.updateStatus(agent.id, 'idle'); + + if (this.eventBus) { + const event: AgentStoppedEvent = { + type: 'agent:stopped', + timestamp: new Date(), + payload: { + agentId: agent.id, + name: agent.name, + taskId: agent.taskId ?? '', + reason: 'context_complete', + }, + }; + this.eventBus.emit(event); + } + break; + } + + case 'questions': { + if (active) { + active.pendingQuestions = { + questions: discussOutput.questions, + }; + } + await this.repository.updateStatus(agent.id, 'waiting_for_input'); + + if (this.eventBus) { + const event: AgentWaitingEvent = { + type: 'agent:waiting', + timestamp: new Date(), + payload: { + agentId: agent.id, + name: agent.name, + taskId: agent.taskId ?? '', + sessionId: sessionId ?? agent.sessionId ?? '', + questions: discussOutput.questions, + }, + }; + this.eventBus.emit(event); + } + break; + } + + case 'unrecoverable_error': { + if (active) { + active.result = { + success: false, + message: discussOutput.error, + }; + } + await this.repository.updateStatus(agent.id, 'crashed'); + + if (this.eventBus) { + const event: AgentCrashedEvent = { + type: 'agent:crashed', + timestamp: new Date(), + payload: { + agentId: agent.id, + name: agent.name, + taskId: agent.taskId ?? '', + error: discussOutput.error, + }, + }; + this.eventBus.emit(event); + } + break; + } + } + } + + /** + * Handle output for breakdown mode. + * Outputs phases array when initiative decomposition is complete. + */ + private async handleBreakdownOutput( + agent: { id: string; name: string; taskId: string | null }, + rawOutput: unknown + ): Promise { + const breakdownOutput = breakdownOutputSchema.parse(rawOutput); + const active = this.activeAgents.get(agent.id); + + switch (breakdownOutput.status) { + case 'breakdown_complete': { + if (active) { + active.result = { + success: true, + message: `Breakdown complete with ${breakdownOutput.phases.length} phases`, + }; + } + await this.repository.updateStatus(agent.id, 'idle'); + + if (this.eventBus) { + const event: AgentStoppedEvent = { + type: 'agent:stopped', + timestamp: new Date(), + payload: { + agentId: agent.id, + name: agent.name, + taskId: agent.taskId ?? '', + reason: 'breakdown_complete', + }, + }; + this.eventBus.emit(event); + } + break; + } + + case 'questions': { + if (active) { + active.pendingQuestions = { + questions: breakdownOutput.questions, + }; + } + await this.repository.updateStatus(agent.id, 'waiting_for_input'); + + if (this.eventBus) { + const event: AgentWaitingEvent = { + type: 'agent:waiting', + timestamp: new Date(), + payload: { + agentId: agent.id, + name: agent.name, + taskId: agent.taskId ?? '', + sessionId: '', + questions: breakdownOutput.questions, + }, + }; + this.eventBus.emit(event); + } + break; + } + + case 'unrecoverable_error': { + if (active) { + active.result = { + success: false, + message: breakdownOutput.error, + }; + } + await this.repository.updateStatus(agent.id, 'crashed'); + + if (this.eventBus) { + const event: AgentCrashedEvent = { + type: 'agent:crashed', + timestamp: new Date(), + payload: { + agentId: agent.id, + name: agent.name, + taskId: agent.taskId ?? '', + error: breakdownOutput.error, + }, + }; + this.eventBus.emit(event); + } + break; + } + } + } + /** * Handle agent errors - actual crashes (not waiting for input). * With structured output via --json-schema, question status is handled in @@ -377,7 +603,8 @@ export class ClaudeAgentManager implements AgentManager { await this.repository.updateStatus(agentId, 'running'); - // Start CLI with --resume flag and same JSON schema + // Start CLI with --resume flag and mode-specific JSON schema + const jsonSchema = this.getJsonSchemaForMode(agent.mode as AgentMode); const subprocess = execa( 'claude', [ @@ -388,7 +615,7 @@ export class ClaudeAgentManager implements AgentManager { '--output-format', 'json', '--json-schema', - JSON.stringify(agentOutputJsonSchema), + JSON.stringify(jsonSchema), ], { cwd: worktree.path,