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
This commit is contained in:
@@ -43,11 +43,12 @@ Add agents table to schema.ts following existing patterns:
|
|||||||
```typescript
|
```typescript
|
||||||
export const agents = sqliteTable('agents', {
|
export const agents = sqliteTable('agents', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
|
name: text('name').notNull().unique(), // Human-readable name (e.g., 'gastown', 'chinatown')
|
||||||
taskId: text('task_id')
|
taskId: text('task_id')
|
||||||
.references(() => tasks.id, { onDelete: 'set null' }), // Task may be deleted
|
.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
|
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()
|
.notNull()
|
||||||
.default('idle'),
|
.default('idle'),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
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:
|
Add relations:
|
||||||
```typescript
|
```typescript
|
||||||
export const agentsRelations = relations(agents, ({ one }) => ({
|
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
|
// src/db/repositories/agent-repository.ts
|
||||||
import type { Agent, NewAgent } from '../schema.js';
|
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 {
|
export interface AgentRepository {
|
||||||
create(agent: Omit<NewAgent, 'createdAt' | 'updatedAt'>): Promise<Agent>;
|
create(agent: Omit<NewAgent, 'createdAt' | 'updatedAt'>): Promise<Agent>;
|
||||||
findById(id: string): Promise<Agent | null>;
|
findById(id: string): Promise<Agent | null>;
|
||||||
|
findByName(name: string): Promise<Agent | null>; // Lookup by human-readable name
|
||||||
findByTaskId(taskId: string): Promise<Agent | null>;
|
findByTaskId(taskId: string): Promise<Agent | null>;
|
||||||
findBySessionId(sessionId: string): Promise<Agent | null>;
|
findBySessionId(sessionId: string): Promise<Agent | null>;
|
||||||
findAll(): Promise<Agent[]>;
|
findAll(): Promise<Agent[]>;
|
||||||
@@ -121,11 +125,13 @@ Create DrizzleAgentRepository following existing patterns (see drizzle/task.ts):
|
|||||||
|
|
||||||
Tests should cover:
|
Tests should cover:
|
||||||
- create() returns agent with timestamps
|
- create() returns agent with timestamps
|
||||||
|
- create() rejects duplicate names
|
||||||
- findById() returns null for non-existent
|
- findById() returns null for non-existent
|
||||||
|
- findByName() finds agent by human-readable name
|
||||||
- findByTaskId() finds agent by task
|
- findByTaskId() finds agent by task
|
||||||
- findBySessionId() finds agent by session
|
- findBySessionId() finds agent by session
|
||||||
- findAll() returns all agents
|
- findAll() returns all agents
|
||||||
- findByStatus() filters correctly
|
- findByStatus() filters correctly (including waiting_for_input)
|
||||||
- updateStatus() changes status and updatedAt
|
- updateStatus() changes status and updatedAt
|
||||||
- updateSessionId() changes sessionId
|
- updateSessionId() changes sessionId
|
||||||
- delete() removes agent
|
- delete() removes agent
|
||||||
|
|||||||
@@ -49,12 +49,14 @@ Create new agent module with port interface following WorktreeManager pattern:
|
|||||||
* AgentManager is the PORT. Implementations are ADAPTERS.
|
* 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
|
* Options for spawning a new agent
|
||||||
*/
|
*/
|
||||||
export interface SpawnAgentOptions {
|
export interface SpawnAgentOptions {
|
||||||
|
/** Human-readable name for the agent (e.g., 'gastown', 'chinatown') */
|
||||||
|
name: string;
|
||||||
/** Task ID to assign to agent */
|
/** Task ID to assign to agent */
|
||||||
taskId: string;
|
taskId: string;
|
||||||
/** Initial prompt/instruction for the agent */
|
/** Initial prompt/instruction for the agent */
|
||||||
@@ -69,13 +71,15 @@ export interface SpawnAgentOptions {
|
|||||||
export interface AgentInfo {
|
export interface AgentInfo {
|
||||||
/** Unique identifier for this agent */
|
/** Unique identifier for this agent */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Human-readable name for the agent */
|
||||||
|
name: string;
|
||||||
/** Task this agent is working on */
|
/** Task this agent is working on */
|
||||||
taskId: string;
|
taskId: string;
|
||||||
/** Claude SDK session ID for resumption */
|
/** Claude CLI session ID for resumption (null until first run completes) */
|
||||||
sessionId: string;
|
sessionId: string | null;
|
||||||
/** WorktreeManager worktree ID */
|
/** WorktreeManager worktree ID */
|
||||||
worktreeId: string;
|
worktreeId: string;
|
||||||
/** Current status */
|
/** Current status (waiting_for_input = paused on AskUserQuestion) */
|
||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
/** When the agent was created */
|
/** When the agent was created */
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -145,13 +149,22 @@ export interface AgentManager {
|
|||||||
get(agentId: string): Promise<AgentInfo | null>;
|
get(agentId: string): Promise<AgentInfo | null>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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<AgentInfo | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* 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 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<void>;
|
resume(agentId: string, prompt: string): Promise<void>;
|
||||||
|
|
||||||
@@ -191,8 +204,8 @@ export interface AgentSpawnedEvent extends DomainEvent {
|
|||||||
type: 'agent:spawned';
|
type: 'agent:spawned';
|
||||||
payload: {
|
payload: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
name: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
sessionId: string;
|
|
||||||
worktreeId: string;
|
worktreeId: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -201,8 +214,9 @@ export interface AgentStoppedEvent extends DomainEvent {
|
|||||||
type: 'agent:stopped';
|
type: 'agent:stopped';
|
||||||
payload: {
|
payload: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
name: string;
|
||||||
taskId: 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';
|
type: 'agent:crashed';
|
||||||
payload: {
|
payload: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
name: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
error: string;
|
error: string;
|
||||||
};
|
};
|
||||||
@@ -219,10 +234,22 @@ export interface AgentResumedEvent extends DomainEvent {
|
|||||||
type: 'agent:resumed';
|
type: 'agent:resumed';
|
||||||
payload: {
|
payload: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
name: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
sessionId: 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.
|
Update the DomainEventType union to include new event types.
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ plan: 03
|
|||||||
type: execute
|
type: execute
|
||||||
wave: 2
|
wave: 2
|
||||||
depends_on: ["04-01", "04-02"]
|
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
|
autonomous: true
|
||||||
---
|
---
|
||||||
|
|
||||||
<objective>
|
<objective>
|
||||||
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.
|
Output: ClaudeAgentManager adapter with comprehensive tests.
|
||||||
</objective>
|
</objective>
|
||||||
|
|
||||||
@@ -33,45 +33,51 @@ Output: ClaudeAgentManager adapter with comprehensive tests.
|
|||||||
@src/git/manager.ts
|
@src/git/manager.ts
|
||||||
@src/db/repositories/agent-repository.ts
|
@src/db/repositories/agent-repository.ts
|
||||||
@src/events/types.ts
|
@src/events/types.ts
|
||||||
|
@src/process/manager.ts
|
||||||
</context>
|
</context>
|
||||||
|
|
||||||
<tasks>
|
<tasks>
|
||||||
|
|
||||||
<task type="auto">
|
<task type="auto">
|
||||||
<name>Task 1: Install Claude Agent SDK</name>
|
<name>Task 1: Implement ClaudeAgentManager adapter</name>
|
||||||
<files>package.json</files>
|
|
||||||
<action>
|
|
||||||
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).
|
|
||||||
</action>
|
|
||||||
<verify>npm ls @anthropic-ai/claude-agent-sdk shows package installed</verify>
|
|
||||||
<done>@anthropic-ai/claude-agent-sdk added to dependencies</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 2: Implement ClaudeAgentManager adapter</name>
|
|
||||||
<files>src/agent/manager.ts, src/agent/index.ts</files>
|
<files>src/agent/manager.ts, src/agent/index.ts</files>
|
||||||
<action>
|
<action>
|
||||||
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
|
```typescript
|
||||||
// src/agent/manager.ts
|
// src/agent/manager.ts
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
import { execa, type ResultPromise } from 'execa';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import type { AgentManager, AgentInfo, SpawnAgentOptions, AgentResult, AgentStatus } from './types.js';
|
import type { AgentManager, AgentInfo, SpawnAgentOptions, AgentResult, AgentStatus } from './types.js';
|
||||||
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
||||||
import type { WorktreeManager } from '../git/types.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 {
|
interface ActiveAgent {
|
||||||
abortController: AbortController;
|
subprocess: ResultPromise;
|
||||||
result?: AgentResult;
|
result?: AgentResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,145 +91,150 @@ export class ClaudeAgentManager implements AgentManager {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async spawn(options: SpawnAgentOptions): Promise<AgentInfo> {
|
async spawn(options: SpawnAgentOptions): Promise<AgentInfo> {
|
||||||
const { taskId, prompt, cwd } = options;
|
const { name, taskId, prompt, cwd } = options;
|
||||||
const agentId = randomUUID();
|
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
|
// 1. Create isolated worktree
|
||||||
const worktree = await this.worktreeManager.create(agentId, branchName);
|
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({
|
const agent = await this.repository.create({
|
||||||
id: agentId,
|
id: agentId,
|
||||||
|
name,
|
||||||
taskId,
|
taskId,
|
||||||
sessionId: '', // Updated after SDK init
|
sessionId: null,
|
||||||
worktreeId: worktree.id,
|
worktreeId: worktree.id,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Start agent execution
|
// 3. Start Claude CLI in background
|
||||||
const abortController = new AbortController();
|
const subprocess = execa('claude', [
|
||||||
this.activeAgents.set(agentId, { abortController });
|
'-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.activeAgents.set(agentId, { subprocess });
|
||||||
this.runAgent(agentId, prompt, cwd ?? worktree.path, abortController.signal)
|
|
||||||
.catch(error => this.handleAgentError(agentId, error));
|
|
||||||
|
|
||||||
return this.toAgentInfo(agent);
|
// Emit spawned event
|
||||||
}
|
|
||||||
|
|
||||||
private async runAgent(
|
|
||||||
agentId: string,
|
|
||||||
prompt: string,
|
|
||||||
cwd: string,
|
|
||||||
signal: AbortSignal
|
|
||||||
): Promise<void> {
|
|
||||||
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) {
|
if (this.eventBus) {
|
||||||
const agent = await this.repository.findById(agentId);
|
|
||||||
if (agent) {
|
|
||||||
const event: AgentSpawnedEvent = {
|
const event: AgentSpawnedEvent = {
|
||||||
type: 'agent:spawned',
|
type: 'agent:spawned',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
payload: {
|
payload: {
|
||||||
agentId,
|
agentId,
|
||||||
taskId: agent.taskId ?? '',
|
name,
|
||||||
sessionId,
|
taskId,
|
||||||
worktreeId: agent.worktreeId,
|
worktreeId: worktree.id,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
this.eventBus.emit(event);
|
this.eventBus.emit(event);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Handle completion in background
|
||||||
|
this.handleAgentCompletion(agentId, subprocess);
|
||||||
|
|
||||||
|
return this.toAgentInfo(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle result
|
private async handleAgentCompletion(agentId: string, subprocess: ResultPromise): Promise<void> {
|
||||||
if (message.type === 'result') {
|
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);
|
const active = this.activeAgents.get(agentId);
|
||||||
if (active) {
|
if (active) {
|
||||||
active.result = {
|
active.result = {
|
||||||
success: message.subtype === 'success',
|
success: result.subtype === 'success',
|
||||||
message: message.subtype === 'success'
|
message: result.result,
|
||||||
? 'Task completed successfully'
|
|
||||||
: 'Task failed',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agent completed successfully
|
// Update status to idle (ready for next prompt or resume)
|
||||||
await this.repository.updateStatus(agentId, 'idle');
|
await this.repository.updateStatus(agentId, 'idle');
|
||||||
|
|
||||||
if (this.eventBus) {
|
if (this.eventBus) {
|
||||||
const agent = await this.repository.findById(agentId);
|
|
||||||
if (agent) {
|
|
||||||
const event: AgentStoppedEvent = {
|
const event: AgentStoppedEvent = {
|
||||||
type: 'agent:stopped',
|
type: 'agent:stopped',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
payload: {
|
payload: {
|
||||||
agentId,
|
agentId,
|
||||||
|
name: agent.name,
|
||||||
taskId: agent.taskId ?? '',
|
taskId: agent.taskId ?? '',
|
||||||
reason: 'task_complete',
|
reason: 'task_complete',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
this.eventBus.emit(event);
|
this.eventBus.emit(event);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
await this.handleAgentError(agentId, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleAgentError(agentId: string, error: unknown): Promise<void> {
|
private async handleAgentError(agentId: string, error: unknown): Promise<void> {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
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
|
// Check if this is a "waiting for input" scenario (agent asked AskUserQuestion)
|
||||||
if (errorMessage === 'Agent stopped by user') {
|
// The CLI exits with a specific pattern when waiting for user input
|
||||||
await this.repository.updateStatus(agentId, 'stopped');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crashed
|
// Actual crash
|
||||||
await this.repository.updateStatus(agentId, 'crashed');
|
await this.repository.updateStatus(agentId, 'crashed');
|
||||||
|
|
||||||
if (this.eventBus) {
|
if (this.eventBus) {
|
||||||
const agent = await this.repository.findById(agentId);
|
|
||||||
if (agent) {
|
|
||||||
const event: AgentCrashedEvent = {
|
const event: AgentCrashedEvent = {
|
||||||
type: 'agent:crashed',
|
type: 'agent:crashed',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
payload: {
|
payload: {
|
||||||
agentId,
|
agentId,
|
||||||
|
name: agent.name,
|
||||||
taskId: agent.taskId ?? '',
|
taskId: agent.taskId ?? '',
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
this.eventBus.emit(event);
|
this.eventBus.emit(event);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Store error result
|
|
||||||
const active = this.activeAgents.get(agentId);
|
const active = this.activeAgents.get(agentId);
|
||||||
if (active) {
|
if (active) {
|
||||||
active.result = {
|
active.result = {
|
||||||
@@ -241,7 +252,7 @@ export class ClaudeAgentManager implements AgentManager {
|
|||||||
|
|
||||||
const active = this.activeAgents.get(agentId);
|
const active = this.activeAgents.get(agentId);
|
||||||
if (active) {
|
if (active) {
|
||||||
active.abortController.abort();
|
active.subprocess.kill('SIGTERM');
|
||||||
this.activeAgents.delete(agentId);
|
this.activeAgents.delete(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +264,7 @@ export class ClaudeAgentManager implements AgentManager {
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
payload: {
|
payload: {
|
||||||
agentId,
|
agentId,
|
||||||
|
name: agent.name,
|
||||||
taskId: agent.taskId ?? '',
|
taskId: agent.taskId ?? '',
|
||||||
reason: 'user_requested',
|
reason: 'user_requested',
|
||||||
},
|
},
|
||||||
@@ -271,18 +283,23 @@ export class ClaudeAgentManager implements AgentManager {
|
|||||||
return agent ? this.toAgentInfo(agent) : null;
|
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> {
|
async resume(agentId: string, prompt: string): Promise<void> {
|
||||||
const agent = await this.repository.findById(agentId);
|
const agent = await this.repository.findById(agentId);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
throw new Error(`Agent '${agentId}' not found`);
|
throw new Error(`Agent '${agentId}' not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (agent.status !== 'idle') {
|
if (agent.status !== 'waiting_for_input') {
|
||||||
throw new Error(`Agent '${agentId}' is not idle (status: ${agent.status})`);
|
throw new Error(`Agent '${agent.name}' is not waiting for input (status: ${agent.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!agent.sessionId) {
|
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
|
// Get worktree path
|
||||||
@@ -291,20 +308,28 @@ export class ClaudeAgentManager implements AgentManager {
|
|||||||
throw new Error(`Worktree '${agent.worktreeId}' not found`);
|
throw new Error(`Worktree '${agent.worktreeId}' not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update status to running
|
|
||||||
await this.repository.updateStatus(agentId, 'running');
|
await this.repository.updateStatus(agentId, 'running');
|
||||||
|
|
||||||
// Create new abort controller
|
// Start CLI with --resume flag
|
||||||
const abortController = new AbortController();
|
const subprocess = execa('claude', [
|
||||||
this.activeAgents.set(agentId, { abortController });
|
'-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) {
|
if (this.eventBus) {
|
||||||
const event: AgentResumedEvent = {
|
const event: AgentResumedEvent = {
|
||||||
type: 'agent:resumed',
|
type: 'agent:resumed',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
payload: {
|
payload: {
|
||||||
agentId,
|
agentId,
|
||||||
|
name: agent.name,
|
||||||
taskId: agent.taskId ?? '',
|
taskId: agent.taskId ?? '',
|
||||||
sessionId: agent.sessionId,
|
sessionId: agent.sessionId,
|
||||||
},
|
},
|
||||||
@@ -312,65 +337,7 @@ export class ClaudeAgentManager implements AgentManager {
|
|||||||
this.eventBus.emit(event);
|
this.eventBus.emit(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run with resume option
|
this.handleAgentCompletion(agentId, subprocess);
|
||||||
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<void> {
|
|
||||||
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<AgentResult | null> {
|
async getResult(agentId: string): Promise<AgentResult | null> {
|
||||||
@@ -378,9 +345,19 @@ export class ClaudeAgentManager implements AgentManager {
|
|||||||
return active?.result ?? null;
|
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 {
|
return {
|
||||||
id: agent.id,
|
id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
taskId: agent.taskId ?? '',
|
taskId: agent.taskId ?? '',
|
||||||
sessionId: agent.sessionId,
|
sessionId: agent.sessionId,
|
||||||
worktreeId: agent.worktreeId,
|
worktreeId: agent.worktreeId,
|
||||||
@@ -399,14 +376,14 @@ export { ClaudeAgentManager } from './manager.js';
|
|||||||
```
|
```
|
||||||
</action>
|
</action>
|
||||||
<verify>npm run build passes with no TypeScript errors</verify>
|
<verify>npm run build passes with no TypeScript errors</verify>
|
||||||
<done>ClaudeAgentManager adapter implemented and exported</done>
|
<done>ClaudeAgentManager adapter implemented using CLI with JSON output</done>
|
||||||
</task>
|
</task>
|
||||||
|
|
||||||
<task type="auto">
|
<task type="auto">
|
||||||
<name>Task 3: Write tests for AgentManager</name>
|
<name>Task 2: Write tests for AgentManager</name>
|
||||||
<files>src/agent/manager.test.ts</files>
|
<files>src/agent/manager.test.ts</files>
|
||||||
<action>
|
<action>
|
||||||
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
|
```typescript
|
||||||
// src/agent/manager.test.ts
|
// 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 type { WorktreeManager, Worktree } from '../git/types.js';
|
||||||
import { EventEmitterBus } from '../events/index.js';
|
import { EventEmitterBus } from '../events/index.js';
|
||||||
|
|
||||||
// Mock the Claude Agent SDK
|
// Mock execa
|
||||||
vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
|
vi.mock('execa', () => ({
|
||||||
query: vi.fn(),
|
execa: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
import { execa } from 'execa';
|
||||||
|
const mockExeca = vi.mocked(execa);
|
||||||
const mockQuery = vi.mocked(query);
|
|
||||||
|
|
||||||
describe('ClaudeAgentManager', () => {
|
describe('ClaudeAgentManager', () => {
|
||||||
let manager: ClaudeAgentManager;
|
let manager: ClaudeAgentManager;
|
||||||
@@ -433,13 +409,14 @@ describe('ClaudeAgentManager', () => {
|
|||||||
|
|
||||||
const mockWorktree: Worktree = {
|
const mockWorktree: Worktree = {
|
||||||
id: 'worktree-123',
|
id: 'worktree-123',
|
||||||
branch: 'agent/test',
|
branch: 'agent/gastown',
|
||||||
path: '/tmp/worktree',
|
path: '/tmp/worktree',
|
||||||
isMainWorktree: false,
|
isMainWorktree: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockAgent = {
|
const mockAgent = {
|
||||||
id: 'agent-123',
|
id: 'agent-123',
|
||||||
|
name: 'gastown',
|
||||||
taskId: 'task-456',
|
taskId: 'task-456',
|
||||||
sessionId: 'session-789',
|
sessionId: 'session-789',
|
||||||
worktreeId: 'worktree-123',
|
worktreeId: 'worktree-123',
|
||||||
@@ -449,12 +426,12 @@ describe('ClaudeAgentManager', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset mocks
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
mockRepository = {
|
mockRepository = {
|
||||||
create: vi.fn().mockResolvedValue(mockAgent),
|
create: vi.fn().mockResolvedValue(mockAgent),
|
||||||
findById: vi.fn().mockResolvedValue(mockAgent),
|
findById: vi.fn().mockResolvedValue(mockAgent),
|
||||||
|
findByName: vi.fn().mockResolvedValue(null), // No duplicate by default
|
||||||
findByTaskId: vi.fn().mockResolvedValue(mockAgent),
|
findByTaskId: vi.fn().mockResolvedValue(mockAgent),
|
||||||
findBySessionId: vi.fn().mockResolvedValue(mockAgent),
|
findBySessionId: vi.fn().mockResolvedValue(mockAgent),
|
||||||
findAll: vi.fn().mockResolvedValue([mockAgent]),
|
findAll: vi.fn().mockResolvedValue([mockAgent]),
|
||||||
@@ -482,151 +459,139 @@ describe('ClaudeAgentManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('spawn', () => {
|
describe('spawn', () => {
|
||||||
it('creates worktree and agent record', async () => {
|
it('creates worktree and agent record with name', async () => {
|
||||||
// Mock query to complete immediately
|
const mockSubprocess = {
|
||||||
mockQuery.mockImplementation(async function* () {
|
pid: 123,
|
||||||
yield { type: 'system', subtype: 'init', session_id: 'sess-123' };
|
kill: vi.fn(),
|
||||||
yield { type: 'result', subtype: 'success' };
|
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({
|
const result = await manager.spawn({
|
||||||
|
name: 'gastown',
|
||||||
taskId: 'task-456',
|
taskId: 'task-456',
|
||||||
prompt: 'Test task',
|
prompt: 'Test task',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockWorktreeManager.create).toHaveBeenCalledWith(
|
expect(mockWorktreeManager.create).toHaveBeenCalledWith(
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
expect.stringContaining('agent/')
|
'agent/gastown' // Uses name for branch
|
||||||
);
|
);
|
||||||
expect(mockRepository.create).toHaveBeenCalled();
|
expect(mockRepository.create).toHaveBeenCalledWith(
|
||||||
expect(result.taskId).toBe('task-456');
|
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[] = [];
|
const events: any[] = [];
|
||||||
eventBus.subscribe((event) => events.push(event));
|
eventBus.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
mockQuery.mockImplementation(async function* () {
|
const mockSubprocess = {
|
||||||
yield { type: 'system', subtype: 'init', session_id: 'sess-123' };
|
pid: 123,
|
||||||
yield { type: 'result', subtype: 'success' };
|
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
|
const spawnedEvent = events.find(e => e.type === 'agent:spawned');
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
expect(spawnedEvent).toBeDefined();
|
||||||
|
expect(spawnedEvent.payload.name).toBe('gastown');
|
||||||
expect(events.some(e => e.type === 'agent:spawned')).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('stop', () => {
|
describe('stop', () => {
|
||||||
it('stops running agent', async () => {
|
it('stops running agent', async () => {
|
||||||
// First spawn an agent
|
const mockSubprocess = {
|
||||||
mockQuery.mockImplementation(async function* () {
|
pid: 123,
|
||||||
yield { type: 'system', subtype: 'init', session_id: 'sess-123' };
|
kill: vi.fn(),
|
||||||
// Hang here - never yield result
|
then: () => new Promise(() => {}), // Never resolves
|
||||||
await new Promise(() => {});
|
catch: () => mockSubprocess,
|
||||||
});
|
};
|
||||||
|
mockExeca.mockReturnValue(mockSubprocess as any);
|
||||||
|
|
||||||
const agent = await manager.spawn({ taskId: 'task-456', prompt: 'Test' });
|
await manager.spawn({ name: 'gastown', taskId: 'task-456', prompt: 'Test' });
|
||||||
await manager.stop(agent.id);
|
await manager.stop(mockAgent.id);
|
||||||
|
|
||||||
expect(mockRepository.updateStatus).toHaveBeenCalledWith(agent.id, 'stopped');
|
expect(mockSubprocess.kill).toHaveBeenCalledWith('SIGTERM');
|
||||||
});
|
expect(mockRepository.updateStatus).toHaveBeenCalledWith(mockAgent.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', () => {
|
describe('list', () => {
|
||||||
it('returns all agents', async () => {
|
it('returns all agents with names', async () => {
|
||||||
const agents = await manager.list();
|
const agents = await manager.list();
|
||||||
|
|
||||||
expect(mockRepository.findAll).toHaveBeenCalled();
|
|
||||||
expect(agents).toHaveLength(1);
|
expect(agents).toHaveLength(1);
|
||||||
expect(agents[0].id).toBe('agent-123');
|
expect(agents[0].name).toBe('gastown');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get', () => {
|
describe('getByName', () => {
|
||||||
it('returns agent by id', async () => {
|
it('finds agent by name', async () => {
|
||||||
const agent = await manager.get('agent-123');
|
mockRepository.findByName = vi.fn().mockResolvedValue(mockAgent);
|
||||||
|
|
||||||
expect(mockRepository.findById).toHaveBeenCalledWith('agent-123');
|
const agent = await manager.getByName('gastown');
|
||||||
expect(agent?.id).toBe('agent-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for non-existent agent', async () => {
|
expect(mockRepository.findByName).toHaveBeenCalledWith('gastown');
|
||||||
mockRepository.findById = vi.fn().mockResolvedValue(null);
|
expect(agent?.name).toBe('gastown');
|
||||||
|
|
||||||
const agent = await manager.get('not-found');
|
|
||||||
|
|
||||||
expect(agent).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('resume', () => {
|
describe('resume', () => {
|
||||||
it('resumes idle agent with existing session', async () => {
|
it('resumes agent waiting for input', async () => {
|
||||||
mockQuery.mockImplementation(async function* () {
|
mockRepository.findById = vi.fn().mockResolvedValue({
|
||||||
yield { type: 'result', subtype: 'success' };
|
...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({
|
mockRepository.findById = vi.fn().mockResolvedValue({
|
||||||
...mockAgent,
|
...mockAgent,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(manager.resume('agent-123', 'Continue')).rejects.toThrow('is not idle');
|
await expect(manager.resume(mockAgent.id, 'Response')).rejects.toThrow('not waiting for input');
|
||||||
});
|
|
||||||
|
|
||||||
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.
|
Tests mock execa since we can't spawn real Claude CLI in tests.
|
||||||
</action>
|
</action>
|
||||||
<verify>npm test -- src/agent/manager.test.ts passes all tests</verify>
|
<verify>npm test -- src/agent/manager.test.ts passes all tests</verify>
|
||||||
<done>ClaudeAgentManager tests pass, verifying spawn, stop, list, get, resume, and getResult</done>
|
<done>ClaudeAgentManager tests pass, verifying spawn with names, stop, list, getByName, resume</done>
|
||||||
</task>
|
</task>
|
||||||
|
|
||||||
</tasks>
|
</tasks>
|
||||||
@@ -635,9 +600,11 @@ Tests mock the Claude Agent SDK since we can't spawn real agents in tests.
|
|||||||
Before declaring plan complete:
|
Before declaring plan complete:
|
||||||
- [ ] npm run build succeeds without errors
|
- [ ] npm run build succeeds without errors
|
||||||
- [ ] npm test passes all agent manager tests
|
- [ ] npm test passes all agent manager tests
|
||||||
- [ ] @anthropic-ai/claude-agent-sdk installed
|
- [ ] ClaudeAgentManager uses CLI with --output-format json
|
||||||
- [ ] ClaudeAgentManager implements all AgentManager methods
|
- [ ] Session ID extracted from CLI JSON output
|
||||||
- [ ] Events emitted on spawn, stop, crash, resume
|
- [ ] Agent names enforced (unique, used for branches)
|
||||||
|
- [ ] waiting_for_input status handled for AskUserQuestion scenarios
|
||||||
|
- [ ] Events include agent name
|
||||||
</verification>
|
</verification>
|
||||||
|
|
||||||
<success_criteria>
|
<success_criteria>
|
||||||
|
|||||||
@@ -89,25 +89,36 @@ Add agent procedures following existing patterns:
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// Input schemas
|
// Input schemas - support lookup by name OR id
|
||||||
const spawnAgentInput = z.object({
|
const spawnAgentInput = z.object({
|
||||||
|
name: z.string(), // Human-readable name (required)
|
||||||
taskId: z.string(),
|
taskId: z.string(),
|
||||||
prompt: z.string(),
|
prompt: z.string(),
|
||||||
cwd: z.string().optional(),
|
cwd: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const stopAgentInput = z.object({
|
const agentIdentifier = z.object({
|
||||||
agentId: z.string(),
|
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({
|
const resumeAgentInput = z.object({
|
||||||
agentId: z.string(),
|
name: z.string().optional(),
|
||||||
|
id: z.string().optional(),
|
||||||
prompt: z.string(),
|
prompt: z.string(),
|
||||||
|
}).refine(data => data.name || data.id, {
|
||||||
|
message: 'Either name or id must be provided',
|
||||||
});
|
});
|
||||||
|
|
||||||
const getAgentInput = z.object({
|
// Helper to resolve agent by name or id
|
||||||
agentId: z.string(),
|
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
|
// Add to router
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
@@ -118,6 +129,7 @@ export const appRouter = router({
|
|||||||
.input(spawnAgentInput)
|
.input(spawnAgentInput)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const agent = await ctx.agentManager.spawn({
|
const agent = await ctx.agentManager.spawn({
|
||||||
|
name: input.name,
|
||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
@@ -126,10 +138,12 @@ export const appRouter = router({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
stopAgent: procedure
|
stopAgent: procedure
|
||||||
.input(stopAgentInput)
|
.input(agentIdentifier)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await ctx.agentManager.stop(input.agentId);
|
const agent = await resolveAgent(ctx, input);
|
||||||
return { success: true };
|
if (!agent) throw new Error('Agent not found');
|
||||||
|
await ctx.agentManager.stop(agent.id);
|
||||||
|
return { success: true, name: agent.name };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listAgents: procedure
|
listAgents: procedure
|
||||||
@@ -138,22 +152,32 @@ export const appRouter = router({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getAgent: procedure
|
getAgent: procedure
|
||||||
.input(getAgentInput)
|
.input(agentIdentifier)
|
||||||
.query(async ({ ctx, input }) => {
|
.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
|
resumeAgent: procedure
|
||||||
.input(resumeAgentInput)
|
.input(resumeAgentInput)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await ctx.agentManager.resume(input.agentId, input.prompt);
|
const agent = await resolveAgent(ctx, input);
|
||||||
return { success: true };
|
if (!agent) throw new Error('Agent not found');
|
||||||
|
await ctx.agentManager.resume(agent.id, input.prompt);
|
||||||
|
return { success: true, name: agent.name };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getAgentResult: procedure
|
getAgentResult: procedure
|
||||||
.input(getAgentInput)
|
.input(agentIdentifier)
|
||||||
.query(async ({ ctx, input }) => {
|
.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:
|
Add CLI commands for agent management using existing tRPC client pattern:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Add commands to CLI
|
// Add commands to CLI - use NAMES as primary identifier (like gastown)
|
||||||
|
|
||||||
// cw agent spawn <taskId> <prompt>
|
// cw agent spawn --name <name> --task <taskId> <prompt>
|
||||||
// Spawns a new agent for the given task
|
// Example: cw agent spawn --name gastown --task task-123 "Fix the auth bug"
|
||||||
program
|
program
|
||||||
.command('agent spawn <taskId> <prompt>')
|
.command('agent spawn <prompt>')
|
||||||
.description('Spawn a new agent to work on a task')
|
.description('Spawn a new agent to work on a task')
|
||||||
|
.requiredOption('--name <name>', 'Human-readable name for the agent (e.g., gastown)')
|
||||||
|
.requiredOption('--task <taskId>', 'Task ID to assign to agent')
|
||||||
.option('--cwd <path>', 'Working directory for agent')
|
.option('--cwd <path>', '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();
|
const client = await getTrpcClient();
|
||||||
try {
|
try {
|
||||||
const agent = await client.spawnAgent.mutate({
|
const agent = await client.spawnAgent.mutate({
|
||||||
taskId,
|
name: options.name,
|
||||||
|
taskId: options.task,
|
||||||
prompt,
|
prompt,
|
||||||
cwd: options.cwd,
|
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(` Task: ${agent.taskId}`);
|
||||||
console.log(` Status: ${agent.status}`);
|
console.log(` Status: ${agent.status}`);
|
||||||
console.log(` Worktree: ${agent.worktreeId}`);
|
console.log(` Worktree: ${agent.worktreeId}`);
|
||||||
@@ -197,15 +225,16 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// cw agent stop <agentId>
|
// cw agent stop <name>
|
||||||
|
// Example: cw agent stop gastown
|
||||||
program
|
program
|
||||||
.command('agent stop <agentId>')
|
.command('agent stop <name>')
|
||||||
.description('Stop a running agent')
|
.description('Stop a running agent by name')
|
||||||
.action(async (agentId: string) => {
|
.action(async (name: string) => {
|
||||||
const client = await getTrpcClient();
|
const client = await getTrpcClient();
|
||||||
try {
|
try {
|
||||||
await client.stopAgent.mutate({ agentId });
|
const result = await client.stopAgent.mutate({ name });
|
||||||
console.log(`Agent ${agentId} stopped`);
|
console.log(`Agent '${result.name}' stopped`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to stop agent:', error);
|
console.error('Failed to stop agent:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -226,7 +255,8 @@ program
|
|||||||
}
|
}
|
||||||
console.log('Agents:');
|
console.log('Agents:');
|
||||||
for (const agent of 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) {
|
} catch (error) {
|
||||||
console.error('Failed to list agents:', error);
|
console.error('Failed to list agents:', error);
|
||||||
@@ -234,21 +264,23 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// cw agent get <agentId>
|
// cw agent get <name>
|
||||||
|
// Example: cw agent get gastown
|
||||||
program
|
program
|
||||||
.command('agent get <agentId>')
|
.command('agent get <name>')
|
||||||
.description('Get agent details')
|
.description('Get agent details by name')
|
||||||
.action(async (agentId: string) => {
|
.action(async (name: string) => {
|
||||||
const client = await getTrpcClient();
|
const client = await getTrpcClient();
|
||||||
try {
|
try {
|
||||||
const agent = await client.getAgent.query({ agentId });
|
const agent = await client.getAgent.query({ name });
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
console.log(`Agent ${agentId} not found`);
|
console.log(`Agent '${name}' not found`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`Agent: ${agent.id}`);
|
console.log(`Agent: ${agent.name}`);
|
||||||
|
console.log(` ID: ${agent.id}`);
|
||||||
console.log(` Task: ${agent.taskId}`);
|
console.log(` Task: ${agent.taskId}`);
|
||||||
console.log(` Session: ${agent.sessionId}`);
|
console.log(` Session: ${agent.sessionId ?? '(none)'}`);
|
||||||
console.log(` Worktree: ${agent.worktreeId}`);
|
console.log(` Worktree: ${agent.worktreeId}`);
|
||||||
console.log(` Status: ${agent.status}`);
|
console.log(` Status: ${agent.status}`);
|
||||||
console.log(` Created: ${agent.createdAt}`);
|
console.log(` Created: ${agent.createdAt}`);
|
||||||
@@ -259,29 +291,31 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// cw agent resume <agentId> <prompt>
|
// cw agent resume <name> <response>
|
||||||
|
// Example: cw agent resume gastown "Use option A"
|
||||||
program
|
program
|
||||||
.command('agent resume <agentId> <prompt>')
|
.command('agent resume <name> <response>')
|
||||||
.description('Resume an idle agent with a new prompt')
|
.description('Resume an agent that is waiting for input')
|
||||||
.action(async (agentId: string, prompt: string) => {
|
.action(async (name: string, response: string) => {
|
||||||
const client = await getTrpcClient();
|
const client = await getTrpcClient();
|
||||||
try {
|
try {
|
||||||
await client.resumeAgent.mutate({ agentId, prompt });
|
const result = await client.resumeAgent.mutate({ name, prompt: response });
|
||||||
console.log(`Agent ${agentId} resumed`);
|
console.log(`Agent '${result.name}' resumed`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to resume agent:', error);
|
console.error('Failed to resume agent:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// cw agent result <agentId>
|
// cw agent result <name>
|
||||||
|
// Example: cw agent result gastown
|
||||||
program
|
program
|
||||||
.command('agent result <agentId>')
|
.command('agent result <name>')
|
||||||
.description('Get agent execution result')
|
.description('Get agent execution result')
|
||||||
.action(async (agentId: string) => {
|
.action(async (name: string) => {
|
||||||
const client = await getTrpcClient();
|
const client = await getTrpcClient();
|
||||||
try {
|
try {
|
||||||
const result = await client.getAgentResult.query({ agentId });
|
const result = await client.getAgentResult.query({ name });
|
||||||
if (!result) {
|
if (!result) {
|
||||||
console.log('No result available (agent may still be running)');
|
console.log('No result available (agent may still be running)');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -10,118 +10,64 @@
|
|||||||
3. How do we persist agent sessions across terminal close/reopen?
|
3. How do we persist agent sessions across terminal close/reopen?
|
||||||
4. How do we manage the process lifecycle?
|
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
|
```json
|
||||||
- Built-in tool execution (no need to implement tool loop)
|
{
|
||||||
- Session persistence via session IDs
|
"type": "result",
|
||||||
- Streaming message output
|
"subtype": "success",
|
||||||
- Multi-turn conversations
|
"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
|
```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+.
|
# Resume a session (when agent was waiting for input)
|
||||||
|
claude -p "Use option A" --resume f38b6614-d740-4441-a123-0bb3bea0d6a9 --output-format json
|
||||||
### 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)
|
### 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
|
Flow:
|
||||||
let sessionId: string | undefined;
|
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 <name> "response"`
|
||||||
|
4. Resume with `claude -p "response" --resume <session_id> --output-format json`
|
||||||
|
|
||||||
// First query: capture session ID
|
### Agent Names
|
||||||
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
|
Agents use human-readable names (e.g., "gastown", "chinatown") instead of UUIDs for better UX:
|
||||||
for await (const message of query({
|
|
||||||
prompt: "Now find all places that call it",
|
```bash
|
||||||
options: { resume: sessionId }
|
# Spawn with name
|
||||||
})) {
|
cw agent spawn --name gastown --task task-123 "Fix the auth bug"
|
||||||
// Full context preserved
|
|
||||||
}
|
# 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
|
### 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
|
```typescript
|
||||||
for await (const message of query({
|
execa('claude', ['-p', prompt, '--output-format', 'json'], {
|
||||||
prompt: task.description,
|
|
||||||
options: {
|
|
||||||
allowedTools: ["Read", "Edit", "Bash", "Glob", "Grep"],
|
|
||||||
cwd: worktree.path, // Agent operates in isolated worktree
|
cwd: worktree.path, // Agent operates in isolated worktree
|
||||||
permissionMode: "bypassPermissions"
|
});
|
||||||
}
|
|
||||||
})) {
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture Decision
|
## Architecture Decision
|
||||||
|
|||||||
Reference in New Issue
Block a user