Files
Codewalkers/.planning/phases/04-agent-lifecycle/04-03-PLAN.md
Lukas May ffe5acceff docs(04): update agent plans with names and CLI approach
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
2026-01-30 19:53:29 +01:00

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
04-01
04-02
src/agent/manager.ts
src/agent/manager.test.ts
src/agent/index.ts
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.

<execution_context> @/.claude/get-shit-done/workflows/execute-plan.md @/.claude/get-shit-done/templates/summary.md </execution_context>

@.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:

{
  "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';
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:
// 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>
After completion, create `.planning/phases/04-agent-lifecycle/04-03-SUMMARY.md`