Key changes: - Add agent names (human-readable like 'gastown') instead of UUID-only - Use Claude CLI with --output-format json instead of SDK streaming - Session ID extracted from CLI JSON output, not SDK init message - Add waiting_for_input status for AskUserQuestion scenarios - Resume flow is for answering agent questions, not general resumption - CLI commands use names as primary identifier
18 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous
| phase | plan | type | wave | depends_on | files_modified | autonomous | |||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-agent-lifecycle | 03 | execute | 2 |
|
|
true |
Purpose: Provide concrete implementation of AgentManager that spawns real Claude agents via CLI. Output: ClaudeAgentManager adapter with comprehensive tests.
<execution_context>
@/.claude/get-shit-done/workflows/execute-plan.md
@/.claude/get-shit-done/templates/summary.md
</execution_context>
@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:
{
"type": "result",
"subtype": "success",
"session_id": "f38b6614-d740-4441-a123-0bb3bea0d6a9",
"result": "..."
}
Use existing ProcessManager pattern (execa) but with JSON output parsing.
// 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<string, ActiveAgent> = new Map();
constructor(
private repository: AgentRepository,
private worktreeManager: WorktreeManager,
private eventBus?: EventBus
) {}
async spawn(options: SpawnAgentOptions): Promise<AgentInfo> {
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<void> {
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<void> {
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<void> {
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<AgentInfo[]> {
const agents = await this.repository.findAll();
return agents.map(a => this.toAgentInfo(a));
}
async get(agentId: string): Promise<AgentInfo | null> {
const agent = await this.repository.findById(agentId);
return agent ? this.toAgentInfo(agent) : null;
}
async getByName(name: string): Promise<AgentInfo | null> {
const agent = await this.repository.findByName(name);
return agent ? this.toAgentInfo(agent) : null;
}
async resume(agentId: string, prompt: string): Promise<void> {
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<AgentResult | null> {
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:
export * from './types.js';
export { ClaudeAgentManager } from './manager.js';
// 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<success_criteria>
- All tasks completed
- All verification checks pass
- No errors or warnings introduced
- AgentManager ready for tRPC integration </success_criteria>