diff --git a/.planning/phases/04-agent-lifecycle/04-01-PLAN.md b/.planning/phases/04-agent-lifecycle/04-01-PLAN.md index 1a9a2cc..77c4744 100644 --- a/.planning/phases/04-agent-lifecycle/04-01-PLAN.md +++ b/.planning/phases/04-agent-lifecycle/04-01-PLAN.md @@ -43,11 +43,12 @@ Add agents table to schema.ts following existing patterns: ```typescript export const agents = sqliteTable('agents', { id: text('id').primaryKey(), + name: text('name').notNull().unique(), // Human-readable name (e.g., 'gastown', 'chinatown') 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 + sessionId: text('session_id'), // Claude CLI session ID for resumption (null until first run completes) worktreeId: text('worktree_id').notNull(), // WorktreeManager worktree ID - status: text('status', { enum: ['idle', 'running', 'stopped', 'crashed'] }) + status: text('status', { enum: ['idle', 'running', 'waiting_for_input', 'stopped', 'crashed'] }) .notNull() .default('idle'), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), @@ -55,6 +56,8 @@ export const agents = sqliteTable('agents', { }); ``` +Note: `name` is unique, human-readable identifier. `sessionId` nullable - populated from CLI JSON output after first run. `waiting_for_input` status is for when agent pauses on AskUserQuestion. + Add relations: ```typescript export const agentsRelations = relations(agents, ({ one }) => ({ @@ -87,11 +90,12 @@ Create AgentRepository port interface following same pattern as TaskRepository: // src/db/repositories/agent-repository.ts import type { Agent, NewAgent } from '../schema.js'; -export type AgentStatus = 'idle' | 'running' | 'stopped' | 'crashed'; +export type AgentStatus = 'idle' | 'running' | 'waiting_for_input' | 'stopped' | 'crashed'; export interface AgentRepository { create(agent: Omit): Promise; findById(id: string): Promise; + findByName(name: string): Promise; // Lookup by human-readable name findByTaskId(taskId: string): Promise; findBySessionId(sessionId: string): Promise; findAll(): Promise; @@ -121,11 +125,13 @@ Create DrizzleAgentRepository following existing patterns (see drizzle/task.ts): Tests should cover: - create() returns agent with timestamps +- create() rejects duplicate names - findById() returns null for non-existent +- findByName() finds agent by human-readable name - findByTaskId() finds agent by task - findBySessionId() finds agent by session - findAll() returns all agents -- findByStatus() filters correctly +- findByStatus() filters correctly (including waiting_for_input) - updateStatus() changes status and updatedAt - updateSessionId() changes sessionId - delete() removes agent diff --git a/.planning/phases/04-agent-lifecycle/04-02-PLAN.md b/.planning/phases/04-agent-lifecycle/04-02-PLAN.md index d38f67f..87502fe 100644 --- a/.planning/phases/04-agent-lifecycle/04-02-PLAN.md +++ b/.planning/phases/04-agent-lifecycle/04-02-PLAN.md @@ -49,12 +49,14 @@ Create new agent module with port interface following WorktreeManager pattern: * AgentManager is the PORT. Implementations are ADAPTERS. */ -export type AgentStatus = 'idle' | 'running' | 'stopped' | 'crashed'; +export type AgentStatus = 'idle' | 'running' | 'waiting_for_input' | 'stopped' | 'crashed'; /** * Options for spawning a new agent */ export interface SpawnAgentOptions { + /** Human-readable name for the agent (e.g., 'gastown', 'chinatown') */ + name: string; /** Task ID to assign to agent */ taskId: string; /** Initial prompt/instruction for the agent */ @@ -69,13 +71,15 @@ export interface SpawnAgentOptions { export interface AgentInfo { /** Unique identifier for this agent */ id: string; + /** Human-readable name for the agent */ + name: string; /** Task this agent is working on */ taskId: string; - /** Claude SDK session ID for resumption */ - sessionId: string; + /** Claude CLI session ID for resumption (null until first run completes) */ + sessionId: string | null; /** WorktreeManager worktree ID */ worktreeId: string; - /** Current status */ + /** Current status (waiting_for_input = paused on AskUserQuestion) */ status: AgentStatus; /** When the agent was created */ createdAt: Date; @@ -145,13 +149,22 @@ export interface AgentManager { get(agentId: string): Promise; /** - * Resume an idle agent with a new prompt. + * Get a specific agent by name. * + * @param name - Agent name (human-readable) + * @returns Agent if found, null otherwise + */ + getByName(name: string): Promise; + + /** + * Resume an agent that's waiting for input. + * + * Used when agent paused on AskUserQuestion and user provides response. * Uses stored session ID to continue with full context. - * Agent must be in 'idle' status. + * Agent must be in 'waiting_for_input' status. * * @param agentId - Agent to resume - * @param prompt - New instruction for the agent + * @param prompt - User's response to continue the agent */ resume(agentId: string, prompt: string): Promise; @@ -191,8 +204,8 @@ export interface AgentSpawnedEvent extends DomainEvent { type: 'agent:spawned'; payload: { agentId: string; + name: string; taskId: string; - sessionId: string; worktreeId: string; }; } @@ -201,8 +214,9 @@ export interface AgentStoppedEvent extends DomainEvent { type: 'agent:stopped'; payload: { agentId: string; + name: string; taskId: string; - reason: 'user_requested' | 'task_complete' | 'error'; + reason: 'user_requested' | 'task_complete' | 'error' | 'waiting_for_input'; }; } @@ -210,6 +224,7 @@ export interface AgentCrashedEvent extends DomainEvent { type: 'agent:crashed'; payload: { agentId: string; + name: string; taskId: string; error: string; }; @@ -219,10 +234,22 @@ export interface AgentResumedEvent extends DomainEvent { type: 'agent:resumed'; payload: { agentId: string; + name: string; taskId: string; sessionId: string; }; } + +export interface AgentWaitingEvent extends DomainEvent { + type: 'agent:waiting'; + payload: { + agentId: string; + name: string; + taskId: string; + sessionId: string; + question: string; // The question being asked + }; +} ``` Update the DomainEventType union to include new event types. diff --git a/.planning/phases/04-agent-lifecycle/04-03-PLAN.md b/.planning/phases/04-agent-lifecycle/04-03-PLAN.md index 26a56fe..d2dcc39 100644 --- a/.planning/phases/04-agent-lifecycle/04-03-PLAN.md +++ b/.planning/phases/04-agent-lifecycle/04-03-PLAN.md @@ -4,14 +4,14 @@ 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] +files_modified: [src/agent/manager.ts, src/agent/manager.test.ts, src/agent/index.ts] autonomous: true --- -Implement ClaudeAgentManager adapter using the Claude Agent SDK. +Implement ClaudeAgentManager adapter using Claude CLI with JSON output. -Purpose: Provide concrete implementation of AgentManager that spawns real Claude agents. +Purpose: Provide concrete implementation of AgentManager that spawns real Claude agents via CLI. Output: ClaudeAgentManager adapter with comprehensive tests. @@ -33,45 +33,51 @@ Output: ClaudeAgentManager adapter with comprehensive tests. @src/git/manager.ts @src/db/repositories/agent-repository.ts @src/events/types.ts +@src/process/manager.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 + Task 1: Implement ClaudeAgentManager adapter src/agent/manager.ts, src/agent/index.ts -Create ClaudeAgentManager implementing AgentManager port: +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 { query } from '@anthropic-ai/claude-agent-sdk'; +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 } from '../events/index.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 { - abortController: AbortController; + subprocess: ResultPromise; result?: AgentResult; } @@ -85,145 +91,150 @@ export class ClaudeAgentManager implements AgentManager { ) {} async spawn(options: SpawnAgentOptions): Promise { - const { taskId, prompt, cwd } = options; + const { name, taskId, prompt, cwd } = options; const agentId = randomUUID(); - const branchName = `agent/${agentId}`; + 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 set after SDK init) + // 2. Create agent record (session ID null until first run completes) const agent = await this.repository.create({ id: agentId, + name, taskId, - sessionId: '', // Updated after SDK init + sessionId: null, worktreeId: worktree.id, status: 'running', }); - // 3. Start agent execution - const abortController = new AbortController(); - this.activeAgents.set(agentId, { abortController }); + // 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 + }); - // Run agent in background (non-blocking) - this.runAgent(agentId, prompt, cwd ?? worktree.path, abortController.signal) - .catch(error => this.handleAgentError(agentId, error)); + 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 runAgent( - agentId: string, - prompt: string, - cwd: string, - signal: AbortSignal - ): Promise { + private async handleAgentCompletion(agentId: string, subprocess: ResultPromise): Promise { try { - let sessionId: string | undefined; + const { stdout, stderr } = await subprocess; + const agent = await this.repository.findById(agentId); + if (!agent) return; - 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'); - } + // Parse JSON result + const result: ClaudeCliResult = JSON.parse(stdout); - // 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', - }; - } - } + // Store session_id for potential resume + if (result.session_id) { + await this.repository.updateSessionId(agentId, result.session_id); } - // Agent completed successfully + // 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 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); - } + 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) { - throw 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 was a user-requested stop - if (errorMessage === 'Agent stopped by user') { - await this.repository.updateStatus(agentId, 'stopped'); - 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'); - // 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', + if (this.eventBus) { + const event: AgentWaitingEvent = { + type: 'agent:waiting', timestamp: new Date(), payload: { agentId, + name: agent.name, taskId: agent.taskId ?? '', - error: errorMessage, + 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); } - // Store error result const active = this.activeAgents.get(agentId); if (active) { active.result = { @@ -241,7 +252,7 @@ export class ClaudeAgentManager implements AgentManager { const active = this.activeAgents.get(agentId); if (active) { - active.abortController.abort(); + active.subprocess.kill('SIGTERM'); this.activeAgents.delete(agentId); } @@ -253,6 +264,7 @@ export class ClaudeAgentManager implements AgentManager { timestamp: new Date(), payload: { agentId, + name: agent.name, taskId: agent.taskId ?? '', reason: 'user_requested', }, @@ -271,18 +283,23 @@ export class ClaudeAgentManager implements AgentManager { 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 !== 'idle') { - throw new Error(`Agent '${agentId}' is not idle (status: ${agent.status})`); + 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 '${agentId}' has no session to resume`); + throw new Error(`Agent '${agent.name}' has no session to resume`); } // Get worktree path @@ -291,20 +308,28 @@ export class ClaudeAgentManager implements AgentManager { 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 }); + // 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 }); - // Emit resumed event if (this.eventBus) { const event: AgentResumedEvent = { type: 'agent:resumed', timestamp: new Date(), payload: { agentId, + name: agent.name, taskId: agent.taskId ?? '', sessionId: agent.sessionId, }, @@ -312,65 +337,7 @@ export class ClaudeAgentManager implements AgentManager { 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; - } + this.handleAgentCompletion(agentId, subprocess); } async getResult(agentId: string): Promise { @@ -378,9 +345,19 @@ export class ClaudeAgentManager implements AgentManager { return active?.result ?? null; } - private toAgentInfo(agent: { id: string; taskId: string | null; sessionId: string; worktreeId: string; status: string; createdAt: Date; updatedAt: Date }): AgentInfo { + 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, @@ -399,14 +376,14 @@ export { ClaudeAgentManager } from './manager.js'; ``` npm run build passes with no TypeScript errors - ClaudeAgentManager adapter implemented and exported + ClaudeAgentManager adapter implemented using CLI with JSON output - Task 3: Write tests for AgentManager + Task 2: 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: +Create unit tests for ClaudeAgentManager. Mock execa since we can't spawn real Claude CLI: ```typescript // src/agent/manager.test.ts @@ -416,14 +393,13 @@ 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(), +// Mock execa +vi.mock('execa', () => ({ + execa: vi.fn(), })); -import { query } from '@anthropic-ai/claude-agent-sdk'; - -const mockQuery = vi.mocked(query); +import { execa } from 'execa'; +const mockExeca = vi.mocked(execa); describe('ClaudeAgentManager', () => { let manager: ClaudeAgentManager; @@ -433,13 +409,14 @@ describe('ClaudeAgentManager', () => { const mockWorktree: Worktree = { id: 'worktree-123', - branch: 'agent/test', + branch: 'agent/gastown', path: '/tmp/worktree', isMainWorktree: false, }; const mockAgent = { id: 'agent-123', + name: 'gastown', taskId: 'task-456', sessionId: 'session-789', worktreeId: 'worktree-123', @@ -449,12 +426,12 @@ describe('ClaudeAgentManager', () => { }; beforeEach(() => { - // Reset mocks 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]), @@ -482,151 +459,139 @@ describe('ClaudeAgentManager', () => { }); 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' }; - }); + 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), - expect.stringContaining('agent/') + 'agent/gastown' // Uses name for branch ); - expect(mockRepository.create).toHaveBeenCalled(); - expect(result.taskId).toBe('task-456'); + expect(mockRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ name: 'gastown' }) + ); + expect(result.name).toBe('gastown'); }); - it('emits AgentSpawned event', async () => { + 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)); - mockQuery.mockImplementation(async function* () { - yield { type: 'system', subtype: 'init', session_id: 'sess-123' }; - yield { type: 'result', subtype: 'success' }; - }); + 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({ taskId: 'task-456', prompt: 'Test' }); + await manager.spawn({ name: 'gastown', 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); + 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 () => { - // 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 mockSubprocess = { + pid: 123, + kill: vi.fn(), + then: () => new Promise(() => {}), // Never resolves + catch: () => mockSubprocess, + }; + mockExeca.mockReturnValue(mockSubprocess as any); - const agent = await manager.spawn({ taskId: 'task-456', prompt: 'Test' }); - await manager.stop(agent.id); + await manager.spawn({ name: 'gastown', taskId: 'task-456', prompt: 'Test' }); + await manager.stop(mockAgent.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"); + expect(mockSubprocess.kill).toHaveBeenCalledWith('SIGTERM'); + expect(mockRepository.updateStatus).toHaveBeenCalledWith(mockAgent.id, 'stopped'); }); }); describe('list', () => { - it('returns all agents', async () => { + it('returns all agents with names', async () => { const agents = await manager.list(); - expect(mockRepository.findAll).toHaveBeenCalled(); expect(agents).toHaveLength(1); - expect(agents[0].id).toBe('agent-123'); + expect(agents[0].name).toBe('gastown'); }); }); - describe('get', () => { - it('returns agent by id', async () => { - const agent = await manager.get('agent-123'); + describe('getByName', () => { + it('finds agent by name', async () => { + mockRepository.findByName = vi.fn().mockResolvedValue(mockAgent); - expect(mockRepository.findById).toHaveBeenCalledWith('agent-123'); - expect(agent?.id).toBe('agent-123'); - }); + const agent = await manager.getByName('gastown'); - it('returns null for non-existent agent', async () => { - mockRepository.findById = vi.fn().mockResolvedValue(null); - - const agent = await manager.get('not-found'); - - expect(agent).toBeNull(); + expect(mockRepository.findByName).toHaveBeenCalledWith('gastown'); + expect(agent?.name).toBe('gastown'); }); }); describe('resume', () => { - it('resumes idle agent with existing session', async () => { - mockQuery.mockImplementation(async function* () { - yield { type: 'result', subtype: 'success' }; + it('resumes agent waiting for input', async () => { + mockRepository.findById = vi.fn().mockResolvedValue({ + ...mockAgent, + status: 'waiting_for_input', }); - await manager.resume('agent-123', 'Continue work'); + 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); - expect(mockRepository.updateStatus).toHaveBeenCalledWith('agent-123', 'running'); + await manager.resume(mockAgent.id, 'User response'); + + expect(mockExeca).toHaveBeenCalledWith('claude', [ + '-p', 'User response', + '--resume', 'session-789', + '--output-format', 'json', + ], expect.any(Object)); }); - it('throws if agent not idle', async () => { + it('rejects if agent not waiting for input', 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(); + await expect(manager.resume(mockAgent.id, 'Response')).rejects.toThrow('not waiting for input'); }); }); }); ``` -Tests mock the Claude Agent SDK since we can't spawn real agents in tests. +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, stop, list, get, resume, and getResult + ClaudeAgentManager tests pass, verifying spawn with names, stop, list, getByName, resume @@ -635,9 +600,11 @@ Tests mock the Claude Agent SDK since we can't spawn real agents in tests. 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 +- [ ] 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 diff --git a/.planning/phases/04-agent-lifecycle/04-04-PLAN.md b/.planning/phases/04-agent-lifecycle/04-04-PLAN.md index 4c5518b..4e2aabe 100644 --- a/.planning/phases/04-agent-lifecycle/04-04-PLAN.md +++ b/.planning/phases/04-agent-lifecycle/04-04-PLAN.md @@ -89,25 +89,36 @@ Add agent procedures following existing patterns: import { z } from 'zod'; -// Input schemas +// Input schemas - support lookup by name OR id const spawnAgentInput = z.object({ + name: z.string(), // Human-readable name (required) taskId: z.string(), prompt: z.string(), cwd: z.string().optional(), }); -const stopAgentInput = z.object({ - agentId: z.string(), +const agentIdentifier = z.object({ + name: z.string().optional(), // Lookup by name (preferred) + id: z.string().optional(), // Or by ID +}).refine(data => data.name || data.id, { + message: 'Either name or id must be provided', }); const resumeAgentInput = z.object({ - agentId: z.string(), + name: z.string().optional(), + id: z.string().optional(), prompt: z.string(), +}).refine(data => data.name || data.id, { + message: 'Either name or id must be provided', }); -const getAgentInput = z.object({ - agentId: z.string(), -}); +// Helper to resolve agent by name or id +async function resolveAgent(ctx: Context, input: { name?: string; id?: string }) { + if (input.name) { + return ctx.agentManager.getByName(input.name); + } + return ctx.agentManager.get(input.id!); +} // Add to router export const appRouter = router({ @@ -118,6 +129,7 @@ export const appRouter = router({ .input(spawnAgentInput) .mutation(async ({ ctx, input }) => { const agent = await ctx.agentManager.spawn({ + name: input.name, taskId: input.taskId, prompt: input.prompt, cwd: input.cwd, @@ -126,10 +138,12 @@ export const appRouter = router({ }), stopAgent: procedure - .input(stopAgentInput) + .input(agentIdentifier) .mutation(async ({ ctx, input }) => { - await ctx.agentManager.stop(input.agentId); - return { success: true }; + const agent = await resolveAgent(ctx, input); + if (!agent) throw new Error('Agent not found'); + await ctx.agentManager.stop(agent.id); + return { success: true, name: agent.name }; }), listAgents: procedure @@ -138,22 +152,32 @@ export const appRouter = router({ }), getAgent: procedure - .input(getAgentInput) + .input(agentIdentifier) .query(async ({ ctx, input }) => { - return ctx.agentManager.get(input.agentId); + return resolveAgent(ctx, input); + }), + + getAgentByName: procedure + .input(z.object({ name: z.string() })) + .query(async ({ ctx, input }) => { + return ctx.agentManager.getByName(input.name); }), resumeAgent: procedure .input(resumeAgentInput) .mutation(async ({ ctx, input }) => { - await ctx.agentManager.resume(input.agentId, input.prompt); - return { success: true }; + const agent = await resolveAgent(ctx, input); + if (!agent) throw new Error('Agent not found'); + await ctx.agentManager.resume(agent.id, input.prompt); + return { success: true, name: agent.name }; }), getAgentResult: procedure - .input(getAgentInput) + .input(agentIdentifier) .query(async ({ ctx, input }) => { - return ctx.agentManager.getResult(input.agentId); + const agent = await resolveAgent(ctx, input); + if (!agent) return null; + return ctx.agentManager.getResult(agent.id); }), }); ``` @@ -171,23 +195,27 @@ Export updated AppRouter type for client. Add CLI commands for agent management using existing tRPC client pattern: ```typescript -// Add commands to CLI +// Add commands to CLI - use NAMES as primary identifier (like gastown) -// cw agent spawn -// Spawns a new agent for the given task +// cw agent spawn --name --task +// Example: cw agent spawn --name gastown --task task-123 "Fix the auth bug" program - .command('agent spawn ') + .command('agent spawn ') .description('Spawn a new agent to work on a task') + .requiredOption('--name ', 'Human-readable name for the agent (e.g., gastown)') + .requiredOption('--task ', 'Task ID to assign to agent') .option('--cwd ', 'Working directory for agent') - .action(async (taskId: string, prompt: string, options: { cwd?: string }) => { + .action(async (prompt: string, options: { name: string; task: string; cwd?: string }) => { const client = await getTrpcClient(); try { const agent = await client.spawnAgent.mutate({ - taskId, + name: options.name, + taskId: options.task, prompt, cwd: options.cwd, }); - console.log(`Agent spawned: ${agent.id}`); + console.log(`Agent '${agent.name}' spawned`); + console.log(` ID: ${agent.id}`); console.log(` Task: ${agent.taskId}`); console.log(` Status: ${agent.status}`); console.log(` Worktree: ${agent.worktreeId}`); @@ -197,15 +225,16 @@ program } }); -// cw agent stop +// cw agent stop +// Example: cw agent stop gastown program - .command('agent stop ') - .description('Stop a running agent') - .action(async (agentId: string) => { + .command('agent stop ') + .description('Stop a running agent by name') + .action(async (name: string) => { const client = await getTrpcClient(); try { - await client.stopAgent.mutate({ agentId }); - console.log(`Agent ${agentId} stopped`); + const result = await client.stopAgent.mutate({ name }); + console.log(`Agent '${result.name}' stopped`); } catch (error) { console.error('Failed to stop agent:', error); process.exit(1); @@ -226,7 +255,8 @@ program } console.log('Agents:'); for (const agent of agents) { - console.log(` ${agent.id} [${agent.status}] - Task: ${agent.taskId}`); + const status = agent.status === 'waiting_for_input' ? 'WAITING' : agent.status.toUpperCase(); + console.log(` ${agent.name} [${status}] - ${agent.taskId}`); } } catch (error) { console.error('Failed to list agents:', error); @@ -234,21 +264,23 @@ program } }); -// cw agent get +// cw agent get +// Example: cw agent get gastown program - .command('agent get ') - .description('Get agent details') - .action(async (agentId: string) => { + .command('agent get ') + .description('Get agent details by name') + .action(async (name: string) => { const client = await getTrpcClient(); try { - const agent = await client.getAgent.query({ agentId }); + const agent = await client.getAgent.query({ name }); if (!agent) { - console.log(`Agent ${agentId} not found`); + console.log(`Agent '${name}' not found`); return; } - console.log(`Agent: ${agent.id}`); + console.log(`Agent: ${agent.name}`); + console.log(` ID: ${agent.id}`); console.log(` Task: ${agent.taskId}`); - console.log(` Session: ${agent.sessionId}`); + console.log(` Session: ${agent.sessionId ?? '(none)'}`); console.log(` Worktree: ${agent.worktreeId}`); console.log(` Status: ${agent.status}`); console.log(` Created: ${agent.createdAt}`); @@ -259,29 +291,31 @@ program } }); -// cw agent resume +// cw agent resume +// Example: cw agent resume gastown "Use option A" program - .command('agent resume ') - .description('Resume an idle agent with a new prompt') - .action(async (agentId: string, prompt: string) => { + .command('agent resume ') + .description('Resume an agent that is waiting for input') + .action(async (name: string, response: string) => { const client = await getTrpcClient(); try { - await client.resumeAgent.mutate({ agentId, prompt }); - console.log(`Agent ${agentId} resumed`); + const result = await client.resumeAgent.mutate({ name, prompt: response }); + console.log(`Agent '${result.name}' resumed`); } catch (error) { console.error('Failed to resume agent:', error); process.exit(1); } }); -// cw agent result +// cw agent result +// Example: cw agent result gastown program - .command('agent result ') + .command('agent result ') .description('Get agent execution result') - .action(async (agentId: string) => { + .action(async (name: string) => { const client = await getTrpcClient(); try { - const result = await client.getAgentResult.query({ agentId }); + const result = await client.getAgentResult.query({ name }); if (!result) { console.log('No result available (agent may still be running)'); return; diff --git a/.planning/phases/04-agent-lifecycle/DISCOVERY.md b/.planning/phases/04-agent-lifecycle/DISCOVERY.md index 41aee36..58a74cc 100644 --- a/.planning/phases/04-agent-lifecycle/DISCOVERY.md +++ b/.planning/phases/04-agent-lifecycle/DISCOVERY.md @@ -10,118 +10,64 @@ 3. How do we persist agent sessions across terminal close/reopen? 4. How do we manage the process lifecycle? -## Key Finding: Claude Agent SDK +## Key Finding: Claude CLI with JSON Output -**The Claude Agent SDK** (formerly Claude Code SDK) is the correct approach. It provides: +Use **Claude CLI** with `--output-format json` flag, not the SDK. The session_id is returned in the JSON result output: -- 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 +```json +{ + "type": "result", + "subtype": "success", + "is_error": false, + "duration_ms": 7899, + "session_id": "f38b6614-d740-4441-a123-0bb3bea0d6a9", + "result": "Claude's response...", + "total_cost_usd": 0.08348025 +} +``` -### Installation +### CLI Invocation Pattern ```bash -npm install @anthropic-ai/claude-agent-sdk -``` +# Spawn agent with JSON output +claude -p "Fix the auth bug in src/auth.ts" --output-format json -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 -} +# Resume a session (when agent was waiting for input) +claude -p "Use option A" --resume f38b6614-d740-4441-a123-0bb3bea0d6a9 --output-format json ``` ### Session Persistence (AGENT-04) -Sessions can be captured and resumed: +**Key insight:** Session ID is primarily important when an agent stops due to waiting for a question (AskUserQuestion tool) and needs to be resumed with the user's response. -```typescript -let sessionId: string | undefined; +Flow: +1. Agent runs and uses AskUserQuestion → CLI exits with session_id +2. Store session_id in database, set status to `waiting_for_input` +3. User provides response via `cw agent resume "response"` +4. Resume with `claude -p "response" --resume --output-format json` -// 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! - } -} +### Agent Names -// 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 -} +Agents use human-readable names (e.g., "gastown", "chinatown") instead of UUIDs for better UX: + +```bash +# Spawn with name +cw agent spawn --name gastown --task task-123 "Fix the auth bug" + +# Reference by name +cw agent stop gastown +cw agent get gastown +cw agent resume gastown "Use option A" ``` -**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: +Each agent gets an isolated worktree. The CLI runs with `cwd` set 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" - } -})) { - // ... -} +execa('claude', ['-p', prompt, '--output-format', 'json'], { + cwd: worktree.path, // Agent operates in isolated worktree +}); ``` ## Architecture Decision