diff --git a/.planning/phases/04-agent-lifecycle/04-01-PLAN.md b/.planning/phases/04-agent-lifecycle/04-01-PLAN.md
new file mode 100644
index 0000000..1a9a2cc
--- /dev/null
+++ b/.planning/phases/04-agent-lifecycle/04-01-PLAN.md
@@ -0,0 +1,161 @@
+---
+phase: 04-agent-lifecycle
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified: [src/db/schema.ts, src/db/repositories/agent-repository.ts, src/db/repositories/drizzle/agent.ts, src/db/repositories/drizzle/agent.test.ts, src/db/repositories/drizzle/index.ts, src/db/repositories/index.ts]
+autonomous: true
+---
+
+
+Add agents table to database schema and create AgentRepository for persistence.
+
+Purpose: Enable agent state persistence for session resumption (AGENT-04) and listing (AGENT-03).
+Output: Database schema with agents table, AgentRepository port and Drizzle adapter with 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
+
+@src/db/schema.ts
+@src/db/repositories/task-repository.ts
+@src/db/repositories/drizzle/task.ts
+@src/db/repositories/drizzle/test-helpers.ts
+
+
+
+
+
+ Task 1: Add agents table to database schema
+ src/db/schema.ts
+
+Add agents table to schema.ts following existing patterns:
+
+```typescript
+export const agents = sqliteTable('agents', {
+ id: text('id').primaryKey(),
+ taskId: text('task_id')
+ .references(() => tasks.id, { onDelete: 'set null' }), // Task may be deleted
+ sessionId: text('session_id').notNull(), // Claude SDK session ID for resumption
+ worktreeId: text('worktree_id').notNull(), // WorktreeManager worktree ID
+ status: text('status', { enum: ['idle', 'running', 'stopped', 'crashed'] })
+ .notNull()
+ .default('idle'),
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
+});
+```
+
+Add relations:
+```typescript
+export const agentsRelations = relations(agents, ({ one }) => ({
+ task: one(tasks, {
+ fields: [agents.taskId],
+ references: [tasks.id],
+ }),
+}));
+```
+
+Export types:
+```typescript
+export type Agent = InferSelectModel;
+export type NewAgent = InferInsertModel;
+```
+
+Note: taskId is nullable with onDelete: 'set null' because agent may outlive task.
+
+ npm run build passes with no TypeScript errors
+ agents table defined in schema with relations and exported types
+
+
+
+ Task 2: Create AgentRepository port interface
+ src/db/repositories/agent-repository.ts, src/db/repositories/index.ts
+
+Create AgentRepository port interface following same pattern as TaskRepository:
+
+```typescript
+// src/db/repositories/agent-repository.ts
+import type { Agent, NewAgent } from '../schema.js';
+
+export type AgentStatus = 'idle' | 'running' | 'stopped' | 'crashed';
+
+export interface AgentRepository {
+ create(agent: Omit): Promise;
+ findById(id: string): Promise;
+ findByTaskId(taskId: string): Promise;
+ findBySessionId(sessionId: string): Promise;
+ findAll(): Promise;
+ findByStatus(status: AgentStatus): Promise;
+ updateStatus(id: string, status: AgentStatus): Promise;
+ updateSessionId(id: string, sessionId: string): Promise;
+ delete(id: string): Promise;
+}
+```
+
+Export from index.ts barrel file.
+
+ npm run build passes
+ AgentRepository interface exported from src/db/repositories/
+
+
+
+ Task 3: Create DrizzleAgentRepository adapter with tests
+ src/db/repositories/drizzle/agent.ts, src/db/repositories/drizzle/agent.test.ts, src/db/repositories/drizzle/index.ts
+
+Create DrizzleAgentRepository following existing patterns (see drizzle/task.ts):
+
+1. Implement all AgentRepository methods using Drizzle ORM
+2. Use eq() for queries, set() for updates
+3. Fetch after insert to ensure schema defaults applied
+4. Throw on not found for update/delete operations
+
+Tests should cover:
+- create() returns agent with timestamps
+- findById() returns null for non-existent
+- findByTaskId() finds agent by task
+- findBySessionId() finds agent by session
+- findAll() returns all agents
+- findByStatus() filters correctly
+- updateStatus() changes status and updatedAt
+- updateSessionId() changes sessionId
+- delete() removes agent
+
+Use createTestDatabase() helper from test-helpers.ts for isolated test databases.
+
+Export DrizzleAgentRepository from drizzle/index.ts.
+
+ npm test -- src/db/repositories/drizzle/agent.test.ts passes all tests
+ DrizzleAgentRepository implemented with passing tests, exported from barrel file
+
+
+
+
+
+Before declaring plan complete:
+- [ ] npm run build succeeds without errors
+- [ ] npm test passes all agent repository tests
+- [ ] agents table has correct columns and relations
+- [ ] AgentRepository interface is exported
+- [ ] DrizzleAgentRepository is exported from drizzle/index.ts
+
+
+
+- All tasks completed
+- All verification checks pass
+- No errors or warnings introduced
+- Agent persistence layer ready for AgentManager adapter
+
+
+
diff --git a/.planning/phases/04-agent-lifecycle/04-02-PLAN.md b/.planning/phases/04-agent-lifecycle/04-02-PLAN.md
new file mode 100644
index 0000000..d38f67f
--- /dev/null
+++ b/.planning/phases/04-agent-lifecycle/04-02-PLAN.md
@@ -0,0 +1,255 @@
+---
+phase: 04-agent-lifecycle
+plan: 02
+type: execute
+wave: 1
+depends_on: []
+files_modified: [src/agent/types.ts, src/agent/index.ts, src/events/types.ts, src/events/index.ts]
+autonomous: true
+---
+
+
+Define AgentManager port interface and agent lifecycle domain events.
+
+Purpose: Establish the contract for agent operations following hexagonal architecture.
+Output: AgentManager port interface and AgentSpawned/AgentStopped/AgentCrashed events.
+
+
+
+@~/.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
+
+@src/events/types.ts
+@src/events/index.ts
+@src/git/types.ts
+
+
+
+
+
+ Task 1: Define AgentManager port interface and domain types
+ src/agent/types.ts, src/agent/index.ts
+
+Create new agent module with port interface following WorktreeManager pattern:
+
+```typescript
+// src/agent/types.ts
+
+/**
+ * Agent Module Types
+ *
+ * Port interface for agent lifecycle management.
+ * AgentManager is the PORT. Implementations are ADAPTERS.
+ */
+
+export type AgentStatus = 'idle' | 'running' | 'stopped' | 'crashed';
+
+/**
+ * Options for spawning a new agent
+ */
+export interface SpawnAgentOptions {
+ /** Task ID to assign to agent */
+ taskId: string;
+ /** Initial prompt/instruction for the agent */
+ prompt: string;
+ /** Optional working directory (defaults to worktree path) */
+ cwd?: string;
+}
+
+/**
+ * Represents a Claude agent instance
+ */
+export interface AgentInfo {
+ /** Unique identifier for this agent */
+ id: string;
+ /** Task this agent is working on */
+ taskId: string;
+ /** Claude SDK session ID for resumption */
+ sessionId: string;
+ /** WorktreeManager worktree ID */
+ worktreeId: string;
+ /** Current status */
+ status: AgentStatus;
+ /** When the agent was created */
+ createdAt: Date;
+ /** Last activity timestamp */
+ updatedAt: Date;
+}
+
+/**
+ * Result from agent execution
+ */
+export interface AgentResult {
+ /** Whether the task completed successfully */
+ success: boolean;
+ /** Result message or error description */
+ message: string;
+ /** Files modified during execution */
+ filesModified?: string[];
+}
+
+/**
+ * AgentManager Port Interface
+ *
+ * Manages Claude agent lifecycle - spawn, stop, list, resume.
+ *
+ * Covers requirements:
+ * - AGENT-01: Spawn new agent with task assignment
+ * - AGENT-02: Stop running agent
+ * - AGENT-03: List all agents with status
+ * - AGENT-04: Resume agent session
+ * - AGENT-05: Background mode (implementation detail)
+ */
+export interface AgentManager {
+ /**
+ * Spawn a new agent to work on a task.
+ *
+ * Creates isolated worktree, starts Claude SDK session,
+ * and begins executing the prompt.
+ *
+ * @param options - Spawn configuration
+ * @returns Agent info with session ID for later resumption
+ */
+ spawn(options: SpawnAgentOptions): Promise;
+
+ /**
+ * Stop a running agent.
+ *
+ * Gracefully stops the agent's work. Worktree is preserved
+ * for potential resumption.
+ *
+ * @param agentId - Agent to stop
+ */
+ stop(agentId: string): Promise;
+
+ /**
+ * List all agents with their current status.
+ *
+ * @returns Array of all agents
+ */
+ list(): Promise;
+
+ /**
+ * Get a specific agent by ID.
+ *
+ * @param agentId - Agent ID
+ * @returns Agent if found, null otherwise
+ */
+ get(agentId: string): Promise;
+
+ /**
+ * Resume an idle agent with a new prompt.
+ *
+ * Uses stored session ID to continue with full context.
+ * Agent must be in 'idle' status.
+ *
+ * @param agentId - Agent to resume
+ * @param prompt - New instruction for the agent
+ */
+ resume(agentId: string, prompt: string): Promise;
+
+ /**
+ * Get the result of an agent's work.
+ *
+ * Only available after agent completes or stops.
+ *
+ * @param agentId - Agent ID
+ * @returns Result if available, null if agent still running
+ */
+ getResult(agentId: string): Promise;
+}
+```
+
+Create barrel export:
+```typescript
+// src/agent/index.ts
+export * from './types.js';
+```
+
+ npm run build passes with no TypeScript errors
+ AgentManager port interface and types exported from src/agent/
+
+
+
+ Task 2: Add agent lifecycle events to events module
+ src/events/types.ts, src/events/index.ts
+
+Add agent lifecycle events following existing patterns (ProcessSpawned, WorktreeCreated, etc.):
+
+```typescript
+// Add to src/events/types.ts
+
+// Agent Events
+export interface AgentSpawnedEvent extends DomainEvent {
+ type: 'agent:spawned';
+ payload: {
+ agentId: string;
+ taskId: string;
+ sessionId: string;
+ worktreeId: string;
+ };
+}
+
+export interface AgentStoppedEvent extends DomainEvent {
+ type: 'agent:stopped';
+ payload: {
+ agentId: string;
+ taskId: string;
+ reason: 'user_requested' | 'task_complete' | 'error';
+ };
+}
+
+export interface AgentCrashedEvent extends DomainEvent {
+ type: 'agent:crashed';
+ payload: {
+ agentId: string;
+ taskId: string;
+ error: string;
+ };
+}
+
+export interface AgentResumedEvent extends DomainEvent {
+ type: 'agent:resumed';
+ payload: {
+ agentId: string;
+ taskId: string;
+ sessionId: string;
+ };
+}
+```
+
+Update the DomainEventType union to include new event types.
+
+Export new event types from index.ts if not already using * export.
+
+ npm run build passes
+ Agent lifecycle events defined and exported from events module
+
+
+
+
+
+Before declaring plan complete:
+- [ ] npm run build succeeds without errors
+- [ ] AgentManager interface exported from src/agent/
+- [ ] Agent lifecycle events (spawned, stopped, crashed, resumed) in events module
+- [ ] All types properly exported from barrel files
+
+
+
+- All tasks completed
+- All verification checks pass
+- No errors or warnings introduced
+- Port interface ready for adapter implementation
+
+
+
diff --git a/.planning/phases/04-agent-lifecycle/04-03-PLAN.md b/.planning/phases/04-agent-lifecycle/04-03-PLAN.md
new file mode 100644
index 0000000..26a56fe
--- /dev/null
+++ b/.planning/phases/04-agent-lifecycle/04-03-PLAN.md
@@ -0,0 +1,652 @@
+---
+phase: 04-agent-lifecycle
+plan: 03
+type: execute
+wave: 2
+depends_on: ["04-01", "04-02"]
+files_modified: [package.json, src/agent/manager.ts, src/agent/manager.test.ts, src/agent/index.ts]
+autonomous: true
+---
+
+
+Implement ClaudeAgentManager adapter using the Claude Agent SDK.
+
+Purpose: Provide concrete implementation of AgentManager that spawns real Claude agents.
+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
+
+
+
+
+
+ Task 1: Install Claude Agent SDK
+ package.json
+
+Install the Claude Agent SDK:
+
+```bash
+npm install @anthropic-ai/claude-agent-sdk
+```
+
+Verify installation by checking package.json includes the dependency.
+
+Note: SDK requires Node.js 18+ (already satisfied by project).
+
+ npm ls @anthropic-ai/claude-agent-sdk shows package installed
+ @anthropic-ai/claude-agent-sdk added to dependencies
+
+
+
+ Task 2: Implement ClaudeAgentManager adapter
+ src/agent/manager.ts, src/agent/index.ts
+
+Create ClaudeAgentManager implementing AgentManager port:
+
+```typescript
+// src/agent/manager.ts
+import { query } from '@anthropic-ai/claude-agent-sdk';
+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 } from '../events/index.js';
+
+interface ActiveAgent {
+ abortController: AbortController;
+ 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 { taskId, prompt, cwd } = options;
+ const agentId = randomUUID();
+ const branchName = `agent/${agentId}`;
+
+ // 1. Create isolated worktree
+ const worktree = await this.worktreeManager.create(agentId, branchName);
+
+ // 2. Create agent record (session ID set after SDK init)
+ const agent = await this.repository.create({
+ id: agentId,
+ taskId,
+ sessionId: '', // Updated after SDK init
+ worktreeId: worktree.id,
+ status: 'running',
+ });
+
+ // 3. Start agent execution
+ const abortController = new AbortController();
+ this.activeAgents.set(agentId, { abortController });
+
+ // Run agent in background (non-blocking)
+ this.runAgent(agentId, prompt, cwd ?? worktree.path, abortController.signal)
+ .catch(error => this.handleAgentError(agentId, error));
+
+ return this.toAgentInfo(agent);
+ }
+
+ private async runAgent(
+ agentId: string,
+ prompt: string,
+ cwd: string,
+ signal: AbortSignal
+ ): Promise {
+ try {
+ let sessionId: string | undefined;
+
+ for await (const message of query({
+ prompt,
+ options: {
+ allowedTools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'],
+ permissionMode: 'bypassPermissions',
+ cwd,
+ }
+ })) {
+ // Check for abort
+ if (signal.aborted) {
+ throw new Error('Agent stopped by user');
+ }
+
+ // Capture session ID from init message
+ if (message.type === 'system' && message.subtype === 'init') {
+ sessionId = message.session_id;
+ await this.repository.updateSessionId(agentId, sessionId);
+
+ // Emit spawned event now that we have session ID
+ if (this.eventBus) {
+ const agent = await this.repository.findById(agentId);
+ if (agent) {
+ const event: AgentSpawnedEvent = {
+ type: 'agent:spawned',
+ timestamp: new Date(),
+ payload: {
+ agentId,
+ taskId: agent.taskId ?? '',
+ sessionId,
+ worktreeId: agent.worktreeId,
+ },
+ };
+ this.eventBus.emit(event);
+ }
+ }
+ }
+
+ // Handle result
+ if (message.type === 'result') {
+ const active = this.activeAgents.get(agentId);
+ if (active) {
+ active.result = {
+ success: message.subtype === 'success',
+ message: message.subtype === 'success'
+ ? 'Task completed successfully'
+ : 'Task failed',
+ };
+ }
+ }
+ }
+
+ // Agent completed successfully
+ await this.repository.updateStatus(agentId, 'idle');
+
+ if (this.eventBus) {
+ const agent = await this.repository.findById(agentId);
+ if (agent) {
+ const event: AgentStoppedEvent = {
+ type: 'agent:stopped',
+ timestamp: new Date(),
+ payload: {
+ agentId,
+ taskId: agent.taskId ?? '',
+ reason: 'task_complete',
+ },
+ };
+ this.eventBus.emit(event);
+ }
+ }
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ private async handleAgentError(agentId: string, error: unknown): Promise {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+
+ // Check if this was a user-requested stop
+ if (errorMessage === 'Agent stopped by user') {
+ await this.repository.updateStatus(agentId, 'stopped');
+ return;
+ }
+
+ // Crashed
+ await this.repository.updateStatus(agentId, 'crashed');
+
+ if (this.eventBus) {
+ const agent = await this.repository.findById(agentId);
+ if (agent) {
+ const event: AgentCrashedEvent = {
+ type: 'agent:crashed',
+ timestamp: new Date(),
+ payload: {
+ agentId,
+ taskId: agent.taskId ?? '',
+ error: errorMessage,
+ },
+ };
+ this.eventBus.emit(event);
+ }
+ }
+
+ // Store error result
+ 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.abortController.abort();
+ this.activeAgents.delete(agentId);
+ }
+
+ await this.repository.updateStatus(agentId, 'stopped');
+
+ if (this.eventBus) {
+ const event: AgentStoppedEvent = {
+ type: 'agent:stopped',
+ timestamp: new Date(),
+ payload: {
+ agentId,
+ 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 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 !== 'idle') {
+ throw new Error(`Agent '${agentId}' is not idle (status: ${agent.status})`);
+ }
+
+ if (!agent.sessionId) {
+ throw new Error(`Agent '${agentId}' 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`);
+ }
+
+ // Update status to running
+ await this.repository.updateStatus(agentId, 'running');
+
+ // Create new abort controller
+ const abortController = new AbortController();
+ this.activeAgents.set(agentId, { abortController });
+
+ // Emit resumed event
+ if (this.eventBus) {
+ const event: AgentResumedEvent = {
+ type: 'agent:resumed',
+ timestamp: new Date(),
+ payload: {
+ agentId,
+ taskId: agent.taskId ?? '',
+ sessionId: agent.sessionId,
+ },
+ };
+ this.eventBus.emit(event);
+ }
+
+ // Run with resume option
+ this.runAgentResume(agentId, prompt, worktree.path, agent.sessionId, abortController.signal)
+ .catch(error => this.handleAgentError(agentId, error));
+ }
+
+ private async runAgentResume(
+ agentId: string,
+ prompt: string,
+ cwd: string,
+ sessionId: string,
+ signal: AbortSignal
+ ): Promise {
+ try {
+ for await (const message of query({
+ prompt,
+ options: {
+ allowedTools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'],
+ permissionMode: 'bypassPermissions',
+ cwd,
+ resume: sessionId,
+ }
+ })) {
+ if (signal.aborted) {
+ throw new Error('Agent stopped by user');
+ }
+
+ if (message.type === 'result') {
+ const active = this.activeAgents.get(agentId);
+ if (active) {
+ active.result = {
+ success: message.subtype === 'success',
+ message: message.subtype === 'success'
+ ? 'Task completed successfully'
+ : 'Task failed',
+ };
+ }
+ }
+ }
+
+ await this.repository.updateStatus(agentId, 'idle');
+
+ if (this.eventBus) {
+ const agent = await this.repository.findById(agentId);
+ if (agent) {
+ const event: AgentStoppedEvent = {
+ type: 'agent:stopped',
+ timestamp: new Date(),
+ payload: {
+ agentId,
+ taskId: agent.taskId ?? '',
+ reason: 'task_complete',
+ },
+ };
+ this.eventBus.emit(event);
+ }
+ }
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ async getResult(agentId: string): Promise {
+ const active = this.activeAgents.get(agentId);
+ return active?.result ?? null;
+ }
+
+ private toAgentInfo(agent: { id: string; taskId: string | null; sessionId: string; worktreeId: string; status: string; createdAt: Date; updatedAt: Date }): AgentInfo {
+ return {
+ id: agent.id,
+ 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 and exported
+
+
+
+ Task 3: Write tests for AgentManager
+ src/agent/manager.test.ts
+
+Create unit tests for ClaudeAgentManager. Since we can't actually spawn Claude agents in tests, mock the SDK:
+
+```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 the Claude Agent SDK
+vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
+ query: vi.fn(),
+}));
+
+import { query } from '@anthropic-ai/claude-agent-sdk';
+
+const mockQuery = vi.mocked(query);
+
+describe('ClaudeAgentManager', () => {
+ let manager: ClaudeAgentManager;
+ let mockRepository: AgentRepository;
+ let mockWorktreeManager: WorktreeManager;
+ let eventBus: EventEmitterBus;
+
+ const mockWorktree: Worktree = {
+ id: 'worktree-123',
+ branch: 'agent/test',
+ path: '/tmp/worktree',
+ isMainWorktree: false,
+ };
+
+ const mockAgent = {
+ id: 'agent-123',
+ taskId: 'task-456',
+ sessionId: 'session-789',
+ worktreeId: 'worktree-123',
+ status: 'idle' as const,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+
+ beforeEach(() => {
+ // Reset mocks
+ vi.clearAllMocks();
+
+ mockRepository = {
+ create: vi.fn().mockResolvedValue(mockAgent),
+ findById: vi.fn().mockResolvedValue(mockAgent),
+ 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', async () => {
+ // Mock query to complete immediately
+ mockQuery.mockImplementation(async function* () {
+ yield { type: 'system', subtype: 'init', session_id: 'sess-123' };
+ yield { type: 'result', subtype: 'success' };
+ });
+
+ const result = await manager.spawn({
+ taskId: 'task-456',
+ prompt: 'Test task',
+ });
+
+ expect(mockWorktreeManager.create).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.stringContaining('agent/')
+ );
+ expect(mockRepository.create).toHaveBeenCalled();
+ expect(result.taskId).toBe('task-456');
+ });
+
+ it('emits AgentSpawned event', async () => {
+ const events: any[] = [];
+ eventBus.subscribe((event) => events.push(event));
+
+ mockQuery.mockImplementation(async function* () {
+ yield { type: 'system', subtype: 'init', session_id: 'sess-123' };
+ yield { type: 'result', subtype: 'success' };
+ });
+
+ await manager.spawn({ taskId: 'task-456', prompt: 'Test' });
+
+ // Wait for async event
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ expect(events.some(e => e.type === 'agent:spawned')).toBe(true);
+ });
+ });
+
+ describe('stop', () => {
+ it('stops running agent', async () => {
+ // First spawn an agent
+ mockQuery.mockImplementation(async function* () {
+ yield { type: 'system', subtype: 'init', session_id: 'sess-123' };
+ // Hang here - never yield result
+ await new Promise(() => {});
+ });
+
+ const agent = await manager.spawn({ taskId: 'task-456', prompt: 'Test' });
+ await manager.stop(agent.id);
+
+ expect(mockRepository.updateStatus).toHaveBeenCalledWith(agent.id, 'stopped');
+ });
+
+ it('throws for non-existent agent', async () => {
+ mockRepository.findById = vi.fn().mockResolvedValue(null);
+
+ await expect(manager.stop('not-found')).rejects.toThrow("Agent 'not-found' not found");
+ });
+ });
+
+ describe('list', () => {
+ it('returns all agents', async () => {
+ const agents = await manager.list();
+
+ expect(mockRepository.findAll).toHaveBeenCalled();
+ expect(agents).toHaveLength(1);
+ expect(agents[0].id).toBe('agent-123');
+ });
+ });
+
+ describe('get', () => {
+ it('returns agent by id', async () => {
+ const agent = await manager.get('agent-123');
+
+ expect(mockRepository.findById).toHaveBeenCalledWith('agent-123');
+ expect(agent?.id).toBe('agent-123');
+ });
+
+ it('returns null for non-existent agent', async () => {
+ mockRepository.findById = vi.fn().mockResolvedValue(null);
+
+ const agent = await manager.get('not-found');
+
+ expect(agent).toBeNull();
+ });
+ });
+
+ describe('resume', () => {
+ it('resumes idle agent with existing session', async () => {
+ mockQuery.mockImplementation(async function* () {
+ yield { type: 'result', subtype: 'success' };
+ });
+
+ await manager.resume('agent-123', 'Continue work');
+
+ expect(mockRepository.updateStatus).toHaveBeenCalledWith('agent-123', 'running');
+ });
+
+ it('throws if agent not idle', async () => {
+ mockRepository.findById = vi.fn().mockResolvedValue({
+ ...mockAgent,
+ status: 'running',
+ });
+
+ await expect(manager.resume('agent-123', 'Continue')).rejects.toThrow('is not idle');
+ });
+
+ it('throws if no session to resume', async () => {
+ mockRepository.findById = vi.fn().mockResolvedValue({
+ ...mockAgent,
+ sessionId: '',
+ });
+
+ await expect(manager.resume('agent-123', 'Continue')).rejects.toThrow('no session to resume');
+ });
+ });
+
+ describe('getResult', () => {
+ it('returns result after completion', async () => {
+ mockQuery.mockImplementation(async function* () {
+ yield { type: 'system', subtype: 'init', session_id: 'sess-123' };
+ yield { type: 'result', subtype: 'success' };
+ });
+
+ const agent = await manager.spawn({ taskId: 'task-456', prompt: 'Test' });
+
+ // Wait for completion
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ const result = await manager.getResult(agent.id);
+ expect(result?.success).toBe(true);
+ });
+
+ it('returns null for unknown agent', async () => {
+ const result = await manager.getResult('unknown');
+ expect(result).toBeNull();
+ });
+ });
+});
+```
+
+Tests mock the Claude Agent SDK since we can't spawn real agents in tests.
+
+ npm test -- src/agent/manager.test.ts passes all tests
+ ClaudeAgentManager tests pass, verifying spawn, stop, list, get, resume, and getResult
+
+
+
+
+
+Before declaring plan complete:
+- [ ] npm run build succeeds without errors
+- [ ] npm test passes all agent manager tests
+- [ ] @anthropic-ai/claude-agent-sdk installed
+- [ ] ClaudeAgentManager implements all AgentManager methods
+- [ ] Events emitted on spawn, stop, crash, resume
+
+
+
+- All tasks completed
+- All verification checks pass
+- No errors or warnings introduced
+- AgentManager ready for tRPC integration
+
+
+
diff --git a/.planning/phases/04-agent-lifecycle/04-04-PLAN.md b/.planning/phases/04-agent-lifecycle/04-04-PLAN.md
new file mode 100644
index 0000000..4c5518b
--- /dev/null
+++ b/.planning/phases/04-agent-lifecycle/04-04-PLAN.md
@@ -0,0 +1,326 @@
+---
+phase: 04-agent-lifecycle
+plan: 04
+type: execute
+wave: 3
+depends_on: ["04-03"]
+files_modified: [src/trpc/router.ts, src/trpc/context.ts, src/cli/index.ts, src/cli/trpc-client.ts]
+autonomous: true
+---
+
+
+Add agent procedures to tRPC router and CLI commands for agent management.
+
+Purpose: Enable users to spawn, stop, list, and manage agents via CLI (AGENT-01, 02, 03).
+Output: tRPC procedures and CLI commands for full agent lifecycle management.
+
+
+
+@~/.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-03-SUMMARY.md
+
+@src/trpc/router.ts
+@src/trpc/context.ts
+@src/cli/index.ts
+@src/cli/trpc-client.ts
+
+
+
+
+
+ Task 1: Add AgentManager to tRPC context
+ src/trpc/context.ts
+
+Update tRPC context to include AgentManager:
+
+1. Import AgentManager and ClaudeAgentManager
+2. Add agentManager to context type
+3. Create agentManager in context factory (requires repository and worktreeManager from context)
+
+The context should wire up:
+- AgentRepository (from database)
+- WorktreeManager (from git module)
+- EventBus (optional, for event emission)
+
+Example pattern from existing context:
+```typescript
+export interface Context {
+ // ... existing
+ agentManager: AgentManager;
+}
+
+export function createContext(): Context {
+ // ... existing setup
+ const agentRepository = new DrizzleAgentRepository(db);
+ const agentManager = new ClaudeAgentManager(
+ agentRepository,
+ worktreeManager,
+ eventBus
+ );
+ return {
+ // ... existing
+ agentManager,
+ };
+}
+```
+
+Note: Context may need to be async if database/worktree setup is async.
+
+ npm run build passes
+ AgentManager available in tRPC context
+
+
+
+ Task 2: Add agent procedures to tRPC router
+ src/trpc/router.ts
+
+Add agent procedures following existing patterns:
+
+```typescript
+// Add to router.ts
+
+import { z } from 'zod';
+
+// Input schemas
+const spawnAgentInput = z.object({
+ taskId: z.string(),
+ prompt: z.string(),
+ cwd: z.string().optional(),
+});
+
+const stopAgentInput = z.object({
+ agentId: z.string(),
+});
+
+const resumeAgentInput = z.object({
+ agentId: z.string(),
+ prompt: z.string(),
+});
+
+const getAgentInput = z.object({
+ agentId: z.string(),
+});
+
+// Add to router
+export const appRouter = router({
+ // ... existing procedures
+
+ // Agent procedures
+ spawnAgent: procedure
+ .input(spawnAgentInput)
+ .mutation(async ({ ctx, input }) => {
+ const agent = await ctx.agentManager.spawn({
+ taskId: input.taskId,
+ prompt: input.prompt,
+ cwd: input.cwd,
+ });
+ return agent;
+ }),
+
+ stopAgent: procedure
+ .input(stopAgentInput)
+ .mutation(async ({ ctx, input }) => {
+ await ctx.agentManager.stop(input.agentId);
+ return { success: true };
+ }),
+
+ listAgents: procedure
+ .query(async ({ ctx }) => {
+ return ctx.agentManager.list();
+ }),
+
+ getAgent: procedure
+ .input(getAgentInput)
+ .query(async ({ ctx, input }) => {
+ return ctx.agentManager.get(input.agentId);
+ }),
+
+ resumeAgent: procedure
+ .input(resumeAgentInput)
+ .mutation(async ({ ctx, input }) => {
+ await ctx.agentManager.resume(input.agentId, input.prompt);
+ return { success: true };
+ }),
+
+ getAgentResult: procedure
+ .input(getAgentInput)
+ .query(async ({ ctx, input }) => {
+ return ctx.agentManager.getResult(input.agentId);
+ }),
+});
+```
+
+Export updated AppRouter type for client.
+
+ npm run build passes
+ Agent tRPC procedures added: spawn, stop, list, get, resume, getResult
+
+
+
+ Task 3: Add agent CLI commands
+ src/cli/index.ts
+
+Add CLI commands for agent management using existing tRPC client pattern:
+
+```typescript
+// Add commands to CLI
+
+// cw agent spawn
+// Spawns a new agent for the given task
+program
+ .command('agent spawn ')
+ .description('Spawn a new agent to work on a task')
+ .option('--cwd ', 'Working directory for agent')
+ .action(async (taskId: string, prompt: string, options: { cwd?: string }) => {
+ const client = await getTrpcClient();
+ try {
+ const agent = await client.spawnAgent.mutate({
+ taskId,
+ prompt,
+ cwd: options.cwd,
+ });
+ console.log(`Agent spawned: ${agent.id}`);
+ console.log(` Task: ${agent.taskId}`);
+ console.log(` Status: ${agent.status}`);
+ console.log(` Worktree: ${agent.worktreeId}`);
+ } catch (error) {
+ console.error('Failed to spawn agent:', error);
+ process.exit(1);
+ }
+ });
+
+// cw agent stop
+program
+ .command('agent stop ')
+ .description('Stop a running agent')
+ .action(async (agentId: string) => {
+ const client = await getTrpcClient();
+ try {
+ await client.stopAgent.mutate({ agentId });
+ console.log(`Agent ${agentId} stopped`);
+ } catch (error) {
+ console.error('Failed to stop agent:', error);
+ process.exit(1);
+ }
+ });
+
+// cw agent list
+program
+ .command('agent list')
+ .description('List all agents')
+ .action(async () => {
+ const client = await getTrpcClient();
+ try {
+ const agents = await client.listAgents.query();
+ if (agents.length === 0) {
+ console.log('No agents found');
+ return;
+ }
+ console.log('Agents:');
+ for (const agent of agents) {
+ console.log(` ${agent.id} [${agent.status}] - Task: ${agent.taskId}`);
+ }
+ } catch (error) {
+ console.error('Failed to list agents:', error);
+ process.exit(1);
+ }
+ });
+
+// cw agent get
+program
+ .command('agent get ')
+ .description('Get agent details')
+ .action(async (agentId: string) => {
+ const client = await getTrpcClient();
+ try {
+ const agent = await client.getAgent.query({ agentId });
+ if (!agent) {
+ console.log(`Agent ${agentId} not found`);
+ return;
+ }
+ console.log(`Agent: ${agent.id}`);
+ console.log(` Task: ${agent.taskId}`);
+ console.log(` Session: ${agent.sessionId}`);
+ console.log(` Worktree: ${agent.worktreeId}`);
+ console.log(` Status: ${agent.status}`);
+ console.log(` Created: ${agent.createdAt}`);
+ console.log(` Updated: ${agent.updatedAt}`);
+ } catch (error) {
+ console.error('Failed to get agent:', error);
+ process.exit(1);
+ }
+ });
+
+// cw agent resume
+program
+ .command('agent resume ')
+ .description('Resume an idle agent with a new prompt')
+ .action(async (agentId: string, prompt: string) => {
+ const client = await getTrpcClient();
+ try {
+ await client.resumeAgent.mutate({ agentId, prompt });
+ console.log(`Agent ${agentId} resumed`);
+ } catch (error) {
+ console.error('Failed to resume agent:', error);
+ process.exit(1);
+ }
+ });
+
+// cw agent result
+program
+ .command('agent result ')
+ .description('Get agent execution result')
+ .action(async (agentId: string) => {
+ const client = await getTrpcClient();
+ try {
+ const result = await client.getAgentResult.query({ agentId });
+ if (!result) {
+ console.log('No result available (agent may still be running)');
+ return;
+ }
+ console.log(`Result: ${result.success ? 'SUCCESS' : 'FAILED'}`);
+ console.log(` Message: ${result.message}`);
+ if (result.filesModified?.length) {
+ console.log(` Files modified: ${result.filesModified.join(', ')}`);
+ }
+ } catch (error) {
+ console.error('Failed to get result:', error);
+ process.exit(1);
+ }
+ });
+```
+
+Commands use commander.js pattern from existing CLI.
+
+ npm run build passes, cw agent --help shows commands
+ CLI commands added: agent spawn, stop, list, get, resume, result
+
+
+
+
+
+Before declaring plan complete:
+- [ ] npm run build succeeds without errors
+- [ ] cw agent --help shows all agent commands
+- [ ] Agent procedures accessible via tRPC client
+- [ ] All 6 requirements satisfied (AGENT-01 through AGENT-07 except AGENT-06)
+
+
+
+- All tasks completed
+- All verification checks pass
+- No errors or warnings introduced
+- Users can manage agents via CLI
+
+
+
diff --git a/.planning/phases/04-agent-lifecycle/DISCOVERY.md b/.planning/phases/04-agent-lifecycle/DISCOVERY.md
new file mode 100644
index 0000000..41aee36
--- /dev/null
+++ b/.planning/phases/04-agent-lifecycle/DISCOVERY.md
@@ -0,0 +1,220 @@
+# Phase 4: Agent Lifecycle - Discovery
+
+**Research Level:** 2 (Standard Research)
+**Completed:** 2026-01-30
+
+## Research Questions
+
+1. How do we spawn Claude Code agents programmatically?
+2. What's the output format for background/non-interactive agents?
+3. How do we persist agent sessions across terminal close/reopen?
+4. How do we manage the process lifecycle?
+
+## Key Finding: Claude Agent SDK
+
+**The Claude Agent SDK** (formerly Claude Code SDK) is the correct approach. It provides:
+
+- Programmatic agent spawning via `query()` function
+- Built-in tool execution (no need to implement tool loop)
+- Session persistence via session IDs
+- Streaming message output
+- Multi-turn conversations
+
+### Installation
+
+```bash
+npm install @anthropic-ai/claude-agent-sdk
+```
+
+Requires Node.js 18+.
+
+### API Patterns
+
+**Single-turn / autonomous task (V1 - recommended for our use case):**
+
+```typescript
+import { query } from "@anthropic-ai/claude-agent-sdk";
+
+for await (const message of query({
+ prompt: "Find and fix the bug in auth.py",
+ options: {
+ allowedTools: ["Read", "Edit", "Bash"],
+ permissionMode: "bypassPermissions" // For automation
+ }
+})) {
+ if ("result" in message) console.log(message.result);
+}
+```
+
+**Multi-turn sessions (V2 preview):**
+
+```typescript
+import { unstable_v2_createSession } from '@anthropic-ai/claude-agent-sdk'
+
+await using session = unstable_v2_createSession({
+ model: 'claude-sonnet-4-5-20250929'
+})
+
+await session.send('Read the auth module')
+for await (const msg of session.stream()) {
+ // Process messages
+}
+```
+
+### Session Persistence (AGENT-04)
+
+Sessions can be captured and resumed:
+
+```typescript
+let sessionId: string | undefined;
+
+// First query: capture session ID
+for await (const message of query({
+ prompt: "Read the authentication module",
+ options: { allowedTools: ["Read", "Glob"] }
+})) {
+ if (message.type === "system" && message.subtype === "init") {
+ sessionId = message.session_id; // Store this!
+ }
+}
+
+// Later: resume with full context
+for await (const message of query({
+ prompt: "Now find all places that call it",
+ options: { resume: sessionId }
+})) {
+ // Full context preserved
+}
+```
+
+**Decision:** Store `session_id` in database for persistence across terminal sessions.
+
+### Key Options
+
+| Option | Purpose |
+|--------|---------|
+| `allowedTools` | Array of tool names (Read, Edit, Bash, Glob, etc.) |
+| `permissionMode` | "bypassPermissions" for automation, "acceptEdits" for safer mode |
+| `resume` | Session ID to resume from |
+| `maxTurns` | Maximum agentic turns before stopping |
+| `cwd` | Working directory for agent operations |
+
+### Message Types
+
+| Type | Subtype | Contains |
+|------|---------|----------|
+| `system` | `init` | `session_id` - capture this for persistence |
+| `assistant` | - | `message.content` with text blocks |
+| `result` | `success`/`error` | Final result |
+
+### Working Directory & Worktrees
+
+The SDK accepts `cwd` option for working directory. We'll set this to the worktree path:
+
+```typescript
+for await (const message of query({
+ prompt: task.description,
+ options: {
+ allowedTools: ["Read", "Edit", "Bash", "Glob", "Grep"],
+ cwd: worktree.path, // Agent operates in isolated worktree
+ permissionMode: "bypassPermissions"
+ }
+})) {
+ // ...
+}
+```
+
+## Architecture Decision
+
+### AgentManager Port Interface
+
+Following hexagonal architecture (same as EventBus, WorktreeManager):
+
+```typescript
+interface AgentManager {
+ // Spawn new agent with task assignment (AGENT-01)
+ spawn(taskId: string, prompt: string): Promise;
+
+ // Stop running agent (AGENT-02)
+ stop(agentId: string): Promise;
+
+ // List all agents with status (AGENT-03)
+ list(): Promise;
+
+ // Get single agent
+ get(agentId: string): Promise;
+
+ // Resume agent session (AGENT-04)
+ resume(agentId: string, prompt: string): Promise;
+}
+
+interface Agent {
+ id: string;
+ taskId: string;
+ sessionId: string; // Claude SDK session ID for persistence
+ worktreeId: string; // WorktreeManager worktree ID
+ status: 'running' | 'idle' | 'stopped' | 'crashed';
+ createdAt: Date;
+ lastActivityAt: Date;
+}
+```
+
+### Database Schema Addition
+
+Add `agents` table to persist agent state:
+
+```sql
+CREATE TABLE agents (
+ id TEXT PRIMARY KEY,
+ task_id TEXT REFERENCES tasks(id),
+ session_id TEXT NOT NULL, -- Claude SDK session ID
+ worktree_id TEXT NOT NULL, -- WorktreeManager worktree ID
+ status TEXT NOT NULL DEFAULT 'idle',
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+```
+
+### Integration with Existing Infrastructure
+
+1. **WorktreeManager** - Create isolated worktree per agent
+2. **EventBus** - Emit agent lifecycle events (AgentSpawned, AgentStopped, etc.)
+3. **Database** - Persist agent state for session resumption
+4. **tRPC** - Expose agent operations to CLI
+
+## Requirements Mapping
+
+| Requirement | Solution |
+|-------------|----------|
+| AGENT-01: Spawn with task | `AgentManager.spawn(taskId, prompt)` creates worktree, starts SDK query |
+| AGENT-02: Stop agent | `AgentManager.stop(id)` - abort async generator, emit stop event |
+| AGENT-03: List agents | `AgentManager.list()` - query database, return with status |
+| AGENT-04: Session persistence | Store `session_id` in DB, use `resume` option to continue |
+| AGENT-05: Background mode | SDK runs in Node.js event loop, no terminal needed |
+| AGENT-07: JSON output | SDK streams structured messages, not raw CLI output |
+
+## Plan Structure
+
+**4 plans in 3 waves:**
+
+```
+Wave 1 (parallel):
+ 04-01: Agent schema + repository (database layer)
+ 04-02: AgentManager port + events (interface + types)
+
+Wave 2:
+ 04-03: AgentManager adapter (SDK implementation)
+
+Wave 3:
+ 04-04: tRPC integration + CLI commands
+```
+
+## Sources
+
+- [Claude Agent SDK Overview](https://platform.claude.com/docs/en/agent-sdk/overview)
+- [TypeScript SDK Reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
+- [Claude Agent SDK TypeScript V2 Preview](https://platform.claude.com/docs/en/agent-sdk/typescript-v2-preview)
+- [Claude Code CLI Reference](https://code.claude.com/docs/en/cli-reference)
+
+---
+*Discovery completed: 2026-01-30*