--- phase: 04-agent-lifecycle plan: 03 type: execute wave: 2 depends_on: ["04-01", "04-02"] files_modified: [src/agent/manager.ts, src/agent/manager.test.ts, src/agent/index.ts] autonomous: true --- Implement ClaudeAgentManager adapter using Claude CLI with JSON output. Purpose: Provide concrete implementation of AgentManager that spawns real Claude agents via CLI. Output: ClaudeAgentManager adapter with comprehensive tests. @~/.claude/get-shit-done/workflows/execute-plan.md @~/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/04-agent-lifecycle/DISCOVERY.md @.planning/phases/04-agent-lifecycle/04-01-SUMMARY.md @.planning/phases/04-agent-lifecycle/04-02-SUMMARY.md @src/agent/types.ts @src/git/types.ts @src/git/manager.ts @src/db/repositories/agent-repository.ts @src/events/types.ts @src/process/manager.ts Task 1: Implement ClaudeAgentManager adapter src/agent/manager.ts, src/agent/index.ts Create ClaudeAgentManager implementing AgentManager port. **Key insight:** Use `claude -p "prompt" --output-format json` CLI mode, not SDK streaming. The session_id is returned in the JSON result: ```json { "type": "result", "subtype": "success", "session_id": "f38b6614-d740-4441-a123-0bb3bea0d6a9", "result": "..." } ``` Use existing ProcessManager pattern (execa) but with JSON output parsing. ```typescript // src/agent/manager.ts import { execa, type ResultPromise } from 'execa'; import { randomUUID } from 'crypto'; import type { AgentManager, AgentInfo, SpawnAgentOptions, AgentResult, AgentStatus } from './types.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { WorktreeManager } from '../git/types.js'; import type { EventBus, AgentSpawnedEvent, AgentStoppedEvent, AgentCrashedEvent, AgentResumedEvent, AgentWaitingEvent } from '../events/index.js'; interface ClaudeCliResult { type: 'result'; subtype: 'success' | 'error'; is_error: boolean; session_id: string; result: string; total_cost_usd?: number; } interface ActiveAgent { subprocess: ResultPromise; result?: AgentResult; } export class ClaudeAgentManager implements AgentManager { private activeAgents: Map = new Map(); constructor( private repository: AgentRepository, private worktreeManager: WorktreeManager, private eventBus?: EventBus ) {} async spawn(options: SpawnAgentOptions): Promise { const { name, taskId, prompt, cwd } = options; const agentId = randomUUID(); const branchName = `agent/${name}`; // Use name for branch // Check name uniqueness const existing = await this.repository.findByName(name); if (existing) { throw new Error(`Agent with name '${name}' already exists`); } // 1. Create isolated worktree const worktree = await this.worktreeManager.create(agentId, branchName); // 2. Create agent record (session ID null until first run completes) const agent = await this.repository.create({ id: agentId, name, taskId, sessionId: null, worktreeId: worktree.id, status: 'running', }); // 3. Start Claude CLI in background const subprocess = execa('claude', [ '-p', prompt, '--output-format', 'json', ], { cwd: cwd ?? worktree.path, detached: true, stdio: ['ignore', 'pipe', 'pipe'], // Capture stdout/stderr }); this.activeAgents.set(agentId, { subprocess }); // Emit spawned event if (this.eventBus) { const event: AgentSpawnedEvent = { type: 'agent:spawned', timestamp: new Date(), payload: { agentId, name, taskId, worktreeId: worktree.id, }, }; this.eventBus.emit(event); } // Handle completion in background this.handleAgentCompletion(agentId, subprocess); return this.toAgentInfo(agent); } private async handleAgentCompletion(agentId: string, subprocess: ResultPromise): Promise { try { const { stdout, stderr } = await subprocess; const agent = await this.repository.findById(agentId); if (!agent) return; // Parse JSON result const result: ClaudeCliResult = JSON.parse(stdout); // Store session_id for potential resume if (result.session_id) { await this.repository.updateSessionId(agentId, result.session_id); } // Store result const active = this.activeAgents.get(agentId); if (active) { active.result = { success: result.subtype === 'success', message: result.result, }; } // Update status to idle (ready for next prompt or resume) 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); } } catch (error) { await this.handleAgentError(agentId, error); } } private async handleAgentError(agentId: string, error: unknown): Promise { const errorMessage = error instanceof Error ? error.message : String(error); const agent = await this.repository.findById(agentId); if (!agent) return; // Check if this is a "waiting for input" scenario (agent asked AskUserQuestion) // The CLI exits with a specific pattern when waiting for user input if (errorMessage.includes('waiting for input') || errorMessage.includes('user_question')) { 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: agent.sessionId ?? '', question: errorMessage, // Would need to parse actual question }, }; this.eventBus.emit(event); } return; } // Actual crash 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: errorMessage, }, }; this.eventBus.emit(event); } const active = this.activeAgents.get(agentId); if (active) { active.result = { success: false, message: errorMessage, }; } } async stop(agentId: string): Promise { const agent = await this.repository.findById(agentId); if (!agent) { throw new Error(`Agent '${agentId}' not found`); } const active = this.activeAgents.get(agentId); if (active) { active.subprocess.kill('SIGTERM'); this.activeAgents.delete(agentId); } await this.repository.updateStatus(agentId, 'stopped'); if (this.eventBus) { const event: AgentStoppedEvent = { type: 'agent:stopped', timestamp: new Date(), payload: { agentId, name: agent.name, taskId: agent.taskId ?? '', reason: 'user_requested', }, }; this.eventBus.emit(event); } } async list(): Promise { const agents = await this.repository.findAll(); return agents.map(a => this.toAgentInfo(a)); } async get(agentId: string): Promise { const agent = await this.repository.findById(agentId); return agent ? this.toAgentInfo(agent) : null; } async getByName(name: string): Promise { const agent = await this.repository.findByName(name); return agent ? this.toAgentInfo(agent) : null; } async resume(agentId: string, prompt: string): Promise { const agent = await this.repository.findById(agentId); if (!agent) { throw new Error(`Agent '${agentId}' not found`); } if (agent.status !== 'waiting_for_input') { throw new Error(`Agent '${agent.name}' is not waiting for input (status: ${agent.status})`); } if (!agent.sessionId) { throw new Error(`Agent '${agent.name}' has no session to resume`); } // Get worktree path const worktree = await this.worktreeManager.get(agent.worktreeId); if (!worktree) { throw new Error(`Worktree '${agent.worktreeId}' not found`); } await this.repository.updateStatus(agentId, 'running'); // Start CLI with --resume flag const subprocess = execa('claude', [ '-p', prompt, '--resume', agent.sessionId, '--output-format', 'json', ], { cwd: worktree.path, detached: true, stdio: ['ignore', 'pipe', 'pipe'], }); this.activeAgents.set(agentId, { subprocess }); if (this.eventBus) { const event: AgentResumedEvent = { type: 'agent:resumed', timestamp: new Date(), payload: { agentId, name: agent.name, taskId: agent.taskId ?? '', sessionId: agent.sessionId, }, }; this.eventBus.emit(event); } this.handleAgentCompletion(agentId, subprocess); } async getResult(agentId: string): Promise { const active = this.activeAgents.get(agentId); return active?.result ?? null; } private toAgentInfo(agent: { id: string; name: string; taskId: string | null; sessionId: string | null; worktreeId: string; status: string; createdAt: Date; updatedAt: Date; }): AgentInfo { return { id: agent.id, name: agent.name, taskId: agent.taskId ?? '', sessionId: agent.sessionId, worktreeId: agent.worktreeId, status: agent.status as AgentStatus, createdAt: agent.createdAt, updatedAt: agent.updatedAt, }; } } ``` Export from index.ts: ```typescript export * from './types.js'; export { ClaudeAgentManager } from './manager.js'; ``` npm run build passes with no TypeScript errors ClaudeAgentManager adapter implemented using CLI with JSON output Task 2: Write tests for AgentManager src/agent/manager.test.ts Create unit tests for ClaudeAgentManager. Mock execa since we can't spawn real Claude CLI: ```typescript // src/agent/manager.test.ts import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ClaudeAgentManager } from './manager.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { WorktreeManager, Worktree } from '../git/types.js'; import { EventEmitterBus } from '../events/index.js'; // Mock execa vi.mock('execa', () => ({ execa: vi.fn(), })); import { execa } from 'execa'; const mockExeca = vi.mocked(execa); describe('ClaudeAgentManager', () => { let manager: ClaudeAgentManager; let mockRepository: AgentRepository; let mockWorktreeManager: WorktreeManager; let eventBus: EventEmitterBus; const mockWorktree: Worktree = { id: 'worktree-123', branch: 'agent/gastown', path: '/tmp/worktree', isMainWorktree: false, }; const mockAgent = { id: 'agent-123', name: 'gastown', taskId: 'task-456', sessionId: 'session-789', worktreeId: 'worktree-123', status: 'idle' as const, createdAt: new Date(), updatedAt: new Date(), }; beforeEach(() => { vi.clearAllMocks(); mockRepository = { create: vi.fn().mockResolvedValue(mockAgent), findById: vi.fn().mockResolvedValue(mockAgent), findByName: vi.fn().mockResolvedValue(null), // No duplicate by default findByTaskId: vi.fn().mockResolvedValue(mockAgent), findBySessionId: vi.fn().mockResolvedValue(mockAgent), findAll: vi.fn().mockResolvedValue([mockAgent]), findByStatus: vi.fn().mockResolvedValue([mockAgent]), updateStatus: vi.fn().mockResolvedValue({ ...mockAgent, status: 'running' }), updateSessionId: vi.fn().mockResolvedValue({ ...mockAgent, sessionId: 'new-session' }), delete: vi.fn().mockResolvedValue(undefined), }; mockWorktreeManager = { create: vi.fn().mockResolvedValue(mockWorktree), remove: vi.fn().mockResolvedValue(undefined), list: vi.fn().mockResolvedValue([mockWorktree]), get: vi.fn().mockResolvedValue(mockWorktree), diff: vi.fn().mockResolvedValue({ files: [], summary: '' }), merge: vi.fn().mockResolvedValue({ success: true, message: 'ok' }), }; eventBus = new EventEmitterBus(); manager = new ClaudeAgentManager(mockRepository, mockWorktreeManager, eventBus); }); afterEach(() => { vi.restoreAllMocks(); }); describe('spawn', () => { it('creates worktree and agent record with name', async () => { const mockSubprocess = { pid: 123, kill: vi.fn(), then: () => Promise.resolve({ stdout: '{"type":"result","subtype":"success","session_id":"sess-123","result":"done"}', stderr: '' }), catch: () => mockSubprocess, }; mockExeca.mockReturnValue(mockSubprocess as any); const result = await manager.spawn({ name: 'gastown', taskId: 'task-456', prompt: 'Test task', }); expect(mockWorktreeManager.create).toHaveBeenCalledWith( expect.any(String), 'agent/gastown' // Uses name for branch ); expect(mockRepository.create).toHaveBeenCalledWith( expect.objectContaining({ name: 'gastown' }) ); expect(result.name).toBe('gastown'); }); it('rejects duplicate agent names', async () => { mockRepository.findByName = vi.fn().mockResolvedValue(mockAgent); await expect(manager.spawn({ name: 'gastown', taskId: 'task-456', prompt: 'Test', })).rejects.toThrow("Agent with name 'gastown' already exists"); }); it('emits AgentSpawned event with name', async () => { const events: any[] = []; eventBus.subscribe((event) => events.push(event)); const mockSubprocess = { pid: 123, kill: vi.fn(), then: () => Promise.resolve({ stdout: '{"type":"result","subtype":"success","session_id":"sess-123","result":"done"}', stderr: '' }), catch: () => mockSubprocess, }; mockExeca.mockReturnValue(mockSubprocess as any); await manager.spawn({ name: 'gastown', taskId: 'task-456', prompt: 'Test' }); const spawnedEvent = events.find(e => e.type === 'agent:spawned'); expect(spawnedEvent).toBeDefined(); expect(spawnedEvent.payload.name).toBe('gastown'); }); }); describe('stop', () => { it('stops running agent', async () => { const mockSubprocess = { pid: 123, kill: vi.fn(), then: () => new Promise(() => {}), // Never resolves catch: () => mockSubprocess, }; mockExeca.mockReturnValue(mockSubprocess as any); await manager.spawn({ name: 'gastown', taskId: 'task-456', prompt: 'Test' }); await manager.stop(mockAgent.id); expect(mockSubprocess.kill).toHaveBeenCalledWith('SIGTERM'); expect(mockRepository.updateStatus).toHaveBeenCalledWith(mockAgent.id, 'stopped'); }); }); describe('list', () => { it('returns all agents with names', async () => { const agents = await manager.list(); expect(agents).toHaveLength(1); expect(agents[0].name).toBe('gastown'); }); }); describe('getByName', () => { it('finds agent by name', async () => { mockRepository.findByName = vi.fn().mockResolvedValue(mockAgent); const agent = await manager.getByName('gastown'); expect(mockRepository.findByName).toHaveBeenCalledWith('gastown'); expect(agent?.name).toBe('gastown'); }); }); describe('resume', () => { it('resumes agent waiting for input', async () => { mockRepository.findById = vi.fn().mockResolvedValue({ ...mockAgent, status: 'waiting_for_input', }); const mockSubprocess = { pid: 123, kill: vi.fn(), then: () => Promise.resolve({ stdout: '{"type":"result","subtype":"success","session_id":"sess-123","result":"continued"}', stderr: '' }), catch: () => mockSubprocess, }; mockExeca.mockReturnValue(mockSubprocess as any); await manager.resume(mockAgent.id, 'User response'); expect(mockExeca).toHaveBeenCalledWith('claude', [ '-p', 'User response', '--resume', 'session-789', '--output-format', 'json', ], expect.any(Object)); }); it('rejects if agent not waiting for input', async () => { mockRepository.findById = vi.fn().mockResolvedValue({ ...mockAgent, status: 'running', }); await expect(manager.resume(mockAgent.id, 'Response')).rejects.toThrow('not waiting for input'); }); }); }); ``` Tests mock execa since we can't spawn real Claude CLI in tests. npm test -- src/agent/manager.test.ts passes all tests ClaudeAgentManager tests pass, verifying spawn with names, stop, list, getByName, resume Before declaring plan complete: - [ ] npm run build succeeds without errors - [ ] npm test passes all agent manager tests - [ ] ClaudeAgentManager uses CLI with --output-format json - [ ] Session ID extracted from CLI JSON output - [ ] Agent names enforced (unique, used for branches) - [ ] waiting_for_input status handled for AskUserQuestion scenarios - [ ] Events include agent name - All tasks completed - All verification checks pass - No errors or warnings introduced - AgentManager ready for tRPC integration After completion, create `.planning/phases/04-agent-lifecycle/04-03-SUMMARY.md`