Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ program

program
.command("run")
.description("Run a task with N parallel Claude Code agents")
.description("Run a task with N parallel AI coding agents")
.argument("<prompt>", "The coding task to perform")
.option("-n, --attempts <number>", "Number of parallel attempts", "3")
.option("-t, --test-cmd <command>", "Test command to verify results (e.g., 'npm test')")
.option("--timeout <seconds>", "Timeout per agent in seconds", "300")
.option("--model <model>", "Claude model to use", "sonnet")
.option("-r, --runner <name>", "AI coding tool to use (default: claude-code)")
.option("--verbose", "Show detailed output from each agent")
.action(async (prompt: string, opts) => {
const attempts = parseInt(opts.attempts, 10);
Expand Down Expand Up @@ -50,6 +51,7 @@ program
testCmd: opts.testCmd,
timeout,
model: opts.model,
runner: opts.runner,
verbose: opts.verbose ?? false,
});
});
Expand Down
30 changes: 26 additions & 4 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { runClaudeAgent } from "../runners/claude-code.js";
import { getDefaultRunner, getRunner } from "../runners/registry.js";
import { analyzeConvergence, recommend } from "../scoring/convergence.js";
import { runTests } from "../scoring/test-runner.js";
import type { AgentResult, EnsembleResult, RunOptions } from "../types.js";
import { displayApplyInstructions, displayHeader, displayResults } from "../utils/display.js";
import { cleanupBranches, createWorktree, removeWorktree } from "../utils/git.js";
import { cleanupBranches, createWorktree } from "../utils/git.js";

export async function run(opts: RunOptions): Promise<void> {
displayHeader(opts.prompt, opts.attempts, opts.model);

// Resolve runner
const runner = opts.runner ? getRunner(opts.runner) : getDefaultRunner();
if (!runner) {
console.error(` Unknown runner: ${opts.runner}`);
console.error(" Available runners: claude-code");
process.exit(1);
}

const isAvailable = await runner.available();
if (!isAvailable) {
console.error(
` Runner "${runner.name}" is not available. Is ${runner.description} installed?`,
);
process.exit(1);
}

// Clean up any leftover worktrees/branches from previous runs
await cleanupBranches().catch(() => {});

Expand All @@ -25,11 +41,17 @@ export async function run(opts: RunOptions): Promise<void> {
console.log();

// Phase 2: Run agents in parallel
console.log(` Running ${opts.attempts} agents in parallel...`);
console.log(` Running ${opts.attempts} agents in parallel (${runner.name})...`);
console.log();

const agentPromises = worktrees.map(({ id, path }) =>
runClaudeAgent(id, opts.prompt, path, opts.model, opts.timeout, opts.verbose),
runner.run(id, {
prompt: opts.prompt,
worktreePath: path,
model: opts.model,
timeout: opts.timeout,
verbose: opts.verbose,
}),
);

const agents: AgentResult[] = await Promise.all(agentPromises);
Expand Down
27 changes: 27 additions & 0 deletions src/runners/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { AgentResult } from "../types.js";

export interface RunnerOptions {
prompt: string;
worktreePath: string;
model: string;
timeout: number;
verbose: boolean;
}

/**
* Interface for AI coding tool runners. Each runner wraps a specific
* tool (Claude Code, Aider, etc.) and executes a task in a worktree.
*/
export interface Runner {
/** Unique identifier for this runner (e.g., "claude-code", "aider") */
name: string;

/** Human-readable description */
description: string;

/** Check if this runner's tool is installed and available */
available(): Promise<boolean>;

/** Execute a task and return the result */
run(id: number, opts: RunnerOptions): Promise<AgentResult>;
}
199 changes: 104 additions & 95 deletions src/runners/claude-code.ts
Original file line number Diff line number Diff line change
@@ -1,118 +1,127 @@
import { spawn } from "node:child_process";
import type { AgentResult } from "../types.js";
import { getDiff, getDiffStats } from "../utils/git.js";
import type { Runner, RunnerOptions } from "./base.js";

export async function runClaudeAgent(
id: number,
prompt: string,
worktreePath: string,
model: string,
timeout: number,
verbose: boolean,
): Promise<AgentResult> {
const start = Date.now();

async function isClaudeInstalled(): Promise<boolean> {
return new Promise((resolve) => {
let output = "";
let error = "";
let settled = false;
const child = spawn("claude", ["--version"], { stdio: "ignore" });
child.on("close", (code) => resolve(code === 0));
child.on("error", () => resolve(false));
});
}

const args = [
"-p",
prompt,
"--output-format",
"text",
"--model",
model,
"--max-turns",
"50",
"--allowedTools",
"Edit",
"Write",
"Read",
"Glob",
"Grep",
"Bash",
];
export const claudeCodeRunner: Runner = {
name: "claude-code",
description: "Claude Code CLI (claude -p)",

const child = spawn("claude", args, {
cwd: worktreePath,
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});
available: isClaudeInstalled,

child.stdout.on("data", (data: Buffer) => {
const chunk = data.toString();
output += chunk;
if (verbose) {
process.stdout.write(` [agent ${id}] ${chunk}`);
}
});
async run(id: number, opts: RunnerOptions): Promise<AgentResult> {
const start = Date.now();

child.stderr.on("data", (data: Buffer) => {
error += data.toString();
});
return new Promise((resolve) => {
let output = "";
let error = "";
let settled = false;

const args = [
"-p",
opts.prompt,
"--output-format",
"text",
"--model",
opts.model,
"--max-turns",
"50",
"--allowedTools",
"Edit",
"Write",
"Read",
"Glob",
"Grep",
"Bash",
];

const child = spawn("claude", args, {
cwd: opts.worktreePath,
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});

const timer = setTimeout(() => {
if (!settled) {
child.stdout.on("data", (data: Buffer) => {
const chunk = data.toString();
output += chunk;
if (opts.verbose) {
process.stdout.write(` [agent ${id}] ${chunk}`);
}
});

child.stderr.on("data", (data: Buffer) => {
error += data.toString();
});

const timer = setTimeout(() => {
if (!settled) {
settled = true;
child.kill("SIGTERM");
resolve({
id,
worktree: opts.worktreePath,
status: "timeout",
exitCode: -1,
duration: Date.now() - start,
output,
error: `Timed out after ${opts.timeout}s`,
diff: "",
filesChanged: [],
linesAdded: 0,
linesRemoved: 0,
});
}
}, opts.timeout * 1000);

child.on("close", async (code) => {
clearTimeout(timer);
if (settled) return;
settled = true;
child.kill("SIGTERM");

const duration = Date.now() - start;
const diff = await getDiff(opts.worktreePath);
const stats = await getDiffStats(opts.worktreePath);

resolve({
id,
worktree: worktreePath,
status: "timeout",
worktree: opts.worktreePath,
status: code === 0 ? "success" : "error",
exitCode: code ?? 1,
duration,
output,
error: error || undefined,
diff,
...stats,
});
});

child.on("error", (err) => {
clearTimeout(timer);
if (settled) return;
settled = true;

resolve({
id,
worktree: opts.worktreePath,
status: "error",
exitCode: -1,
duration: Date.now() - start,
output,
error: `Timed out after ${timeout}s`,
error: err.message,
diff: "",
filesChanged: [],
linesAdded: 0,
linesRemoved: 0,
});
}
}, timeout * 1000);

child.on("close", async (code) => {
clearTimeout(timer);
if (settled) return;
settled = true;

const duration = Date.now() - start;
const diff = await getDiff(worktreePath);
const stats = await getDiffStats(worktreePath);

resolve({
id,
worktree: worktreePath,
status: code === 0 ? "success" : "error",
exitCode: code ?? 1,
duration,
output,
error: error || undefined,
diff,
...stats,
});
});

child.on("error", (err) => {
clearTimeout(timer);
if (settled) return;
settled = true;

resolve({
id,
worktree: worktreePath,
status: "error",
exitCode: -1,
duration: Date.now() - start,
output,
error: err.message,
diff: "",
filesChanged: [],
linesAdded: 0,
linesRemoved: 0,
});
});
});
}
},
};
36 changes: 36 additions & 0 deletions src/runners/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { getDefaultRunner, getRunner, listRunners } from "./registry.js";

describe("runner registry", () => {
it("returns claude-code runner by name", () => {
const runner = getRunner("claude-code");
assert.ok(runner);
assert.equal(runner.name, "claude-code");
});

it("returns undefined for unknown runner", () => {
const runner = getRunner("nonexistent");
assert.equal(runner, undefined);
});

it("lists all available runners", () => {
const runners = listRunners();
assert.ok(runners.length >= 1);
assert.ok(runners.some((r) => r.name === "claude-code"));
});

it("returns claude-code as default runner", () => {
const runner = getDefaultRunner();
assert.equal(runner.name, "claude-code");
});

it("claude-code runner has required interface fields", () => {
const runner = getRunner("claude-code");
assert.ok(runner);
assert.equal(typeof runner.name, "string");
assert.equal(typeof runner.description, "string");
assert.equal(typeof runner.available, "function");
assert.equal(typeof runner.run, "function");
});
});
16 changes: 16 additions & 0 deletions src/runners/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Runner } from "./base.js";
import { claudeCodeRunner } from "./claude-code.js";

const runners = new Map<string, Runner>([["claude-code", claudeCodeRunner]]);

export function getRunner(name: string): Runner | undefined {
return runners.get(name);
}

export function listRunners(): Runner[] {
return [...runners.values()];
}

export function getDefaultRunner(): Runner {
return claudeCodeRunner;
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface RunOptions {
timeout: number;
model: string;
verbose: boolean;
runner?: string;
}

export interface AgentResult {
Expand Down
Loading