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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Please keep the code dead simple and keep the `src/loop/main.ts` file under 100 lines of code.
Please keep the code dead-simple and keep the `src/loop/main.ts` file under 150 lines of code.

# Quick Commands

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# loop

Dead simple Bun CLI that runs `codex` and `claude` in a loop. The [main loop](https://github.com/axeldelafosse/loop/blob/main/src/loop/main.ts#L14) is ~50 lines of easy-to-read code.
Dead-simple Bun CLI that runs `codex` and `claude` in a loop. The [main loop](https://github.com/axeldelafosse/loop/blob/main/src/loop/main.ts#L14) is ~50 lines of easy-to-read code.

Install:
```bash
Expand Down
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export const runCli = async (argv: string[]): Promise<void> => {
if (opts.tmux && cliDeps.runInTmux(argv)) {
return;
}
const gitWarning = cliDeps.checkGitState();
if (gitWarning) {
console.log(gitWarning);
}
await cliDeps.maybeEnterWorktree(opts);
const task = await cliDeps.resolveTask(opts);
await cliDeps.runLoop(task, opts);
Expand Down
2 changes: 2 additions & 0 deletions src/loop/deps.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { parseArgs } from "./args";
import { checkGitState } from "./git";
import { runLoop } from "./main";
import { runPanel } from "./panel";
import { resolveTask } from "./task";
import { runInTmux } from "./tmux";
import { maybeEnterWorktree } from "./worktree";

export const cliDeps = {
checkGitState,
maybeEnterWorktree,
parseArgs,
resolveTask,
Expand Down
29 changes: 29 additions & 0 deletions src/loop/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface GitResult {
}

const SAFE_NAME_RE = /[^a-z0-9-]+/g;
const MAIN_BRANCHES = new Set(["main", "master"]);

export const decode = (value: Uint8Array | null | undefined): string =>
value ? new TextDecoder().decode(value).trim() : "";
Expand Down Expand Up @@ -38,3 +39,31 @@ export const runGit = (
stdout: decode(result.stdout),
};
};

export const checkGitState = (
deps: { runGit?: (args: string[]) => GitResult } = {}
): string | undefined => {
const git = deps.runGit ?? ((args: string[]) => runGit(process.cwd(), args));

const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
if (branch.exitCode !== 0) {
return undefined;
}

const current = branch.stdout;
if (!MAIN_BRANCHES.has(current)) {
return `[loop] heads up: on branch "${current}", not main`;
}

const behind = git(["rev-list", "--count", "HEAD..@{upstream}"]);
if (behind.exitCode !== 0) {
return undefined;
}

const count = Number.parseInt(behind.stdout, 10);
if (count > 0) {
const commits = count === 1 ? "1 commit" : `${count} commits`;
return `[loop] heads up: local ${current} is ${commits} behind remote`;
}
return undefined;
};
11 changes: 11 additions & 0 deletions src/loop/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,31 +23,39 @@ const runIterations = async (
const shouldReview = reviewers.length > 0;
const { doneSignal, maxIterations } = opts;
console.log(`\n[loop] PLAN.md:\n\n${task}`);

for (let i = 1; i <= maxIterations; i++) {
await iterationCooldown(i);
logIterationHeader(i, maxIterations, opts.agent);

const prompt = buildWorkPrompt(task, doneSignal, opts.proof, reviewNotes);
reviewNotes = "";

const result = await tryRunAgent(opts.agent, prompt, opts, sessionId);
sessionId = undefined;

if (!result) {
continue;
}

if (result.exitCode !== 0) {
console.error(
`\n[loop] ${opts.agent} exited with code ${result.exitCode}`
);
logSessionHint(opts.agent);
continue;
}

const output = `${result.parsed}\n${result.combined}`;
if (!hasSignal(output, doneSignal)) {
continue;
}

if (!shouldReview) {
console.log(`\n[loop] ${doneText(doneSignal)} detected, stopping.`);
return true;
}

const review = await runReview(reviewers, task, opts);
if (review.approved) {
await runDraftPrStep(task, opts);
Expand All @@ -56,10 +64,12 @@ const runIterations = async (
);
return true;
}

const followUp = formatFollowUp(review);
reviewNotes = followUp.notes;
console.log(followUp.log);
}

return false;
};

Expand All @@ -69,6 +79,7 @@ export const runLoop = async (task: string, opts: Options): Promise<void> => {
? createInterface({ input: process.stdin, output: process.stdout })
: undefined;
let loopTask = task;

try {
while (true) {
const done = await runIterations(loopTask, opts, reviewers);
Expand Down
5 changes: 2 additions & 3 deletions src/loop/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { REVIEW_FAIL, REVIEW_PASS } from "./constants";
import { NEWLINE_RE, REVIEW_FAIL, REVIEW_PASS } from "./constants";

const NEWLINE_RE = /\r?\n/;
const SPAWN_TEAM_WITH_WORKTREE_ISOLATION =
"Spawn a team of agents with worktree isolation.";

Expand Down Expand Up @@ -78,7 +77,7 @@ export const buildReviewPrompt = (
): string => {
const parts = [
`Review this completed work for the task below and verify it in the current repo.\n\nTask:\n${task.trim()}`,
"Run checks/tests/commands as needed and inspect changed files.",
"Focus your review on unstaged changes (the diff produced by `git diff`). Run checks/tests/commands as needed.",
];

appendProofRequirements(parts, proof);
Expand Down
17 changes: 7 additions & 10 deletions src/loop/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,13 @@ import { NEWLINE_RE } from "./constants";
export const isFile = (path: string): boolean =>
existsSync(path) && statSync(path).isFile();

export const hasSignal = (text: string, signal: string): boolean =>
text
.split(NEWLINE_RE)
.map((line) => line.trim())
.some(
(line) =>
line === signal ||
line === `"${signal}"` ||
line.includes(`"${signal}"`)
);
export const hasSignal = (text: string, signal: string): boolean => {
const quoted = `"${signal}"`;
return text.split(NEWLINE_RE).some((raw) => {
const line = raw.trim();
return line === signal || line === quoted || line.includes(quoted);
});
};

export const readPrompt = async (input: string): Promise<string> => {
if (!isFile(input)) {
Expand Down
4 changes: 4 additions & 0 deletions tests/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ afterEach(() => {
});

interface CliModuleDeps {
checkGitState?: () => string | undefined;
maybeEnterWorktree?: (opts: Options) => void | Promise<void>;
parseArgs?: (argv: string[]) => Options;
resolveTask?: (opts: Options) => Promise<string>;
Expand All @@ -36,6 +37,7 @@ const loadRunCli = async (
deps: CliModuleDeps = {},
updateOverrides: UpdateModuleDeps = {}
) => {
const checkGitStateMock = mock(deps.checkGitState ?? (() => undefined));
const maybeEnterWorktreeMock = mock(
deps.maybeEnterWorktree ?? (() => undefined)
);
Expand All @@ -57,6 +59,7 @@ const loadRunCli = async (

mock.module("../src/loop/deps", () => ({
cliDeps: {
checkGitState: checkGitStateMock,
maybeEnterWorktree: maybeEnterWorktreeMock,
parseArgs: parseArgsMock,
resolveTask: resolveTaskMock,
Expand All @@ -77,6 +80,7 @@ const loadRunCli = async (
const { runCli } = await import(`../src/cli?test=${Date.now()}`);
return {
applyStagedMock,
checkGitStateMock,
handleManualMock,
maybeEnterWorktreeMock,
parseArgsMock,
Expand Down
84 changes: 84 additions & 0 deletions tests/loop/git.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { expect, test } from "bun:test";
import { checkGitState, type GitResult } from "../../src/loop/git";

const ok = (stdout: string): GitResult => ({ exitCode: 0, stderr: "", stdout });
const fail = (): GitResult => ({ exitCode: 1, stderr: "", stdout: "" });

test("returns warning when on a non-main branch", () => {
const result = checkGitState({
runGit: (args) => {
if (args[0] === "rev-parse") {
return ok("feature-xyz");
}
throw new Error(`unexpected: ${args.join(" ")}`);
},
});
expect(result).toBe('[loop] heads up: on branch "feature-xyz", not main');
});

test("returns warning when local main is behind remote", () => {
const result = checkGitState({
runGit: (args) => {
if (args[0] === "rev-parse") {
return ok("main");
}
if (args[0] === "rev-list") {
return ok("3");
}
throw new Error(`unexpected: ${args.join(" ")}`);
},
});
expect(result).toBe("[loop] heads up: local main is 3 commits behind remote");
});
Comment thread
axeldelafosse marked this conversation as resolved.

test("returns warning when local master is behind remote", () => {
const result = checkGitState({
runGit: (args) => {
if (args[0] === "rev-parse") {
return ok("master");
}
if (args[0] === "rev-list") {
return ok("1");
}
throw new Error(`unexpected: ${args.join(" ")}`);
},
});
expect(result).toBe(
"[loop] heads up: local master is 1 commit behind remote"
);
});

test("returns undefined when on main and up to date", () => {
const result = checkGitState({
runGit: (args) => {
if (args[0] === "rev-parse") {
return ok("main");
}
if (args[0] === "rev-list") {
return ok("0");
}
throw new Error(`unexpected: ${args.join(" ")}`);
},
});
expect(result).toBeUndefined();
});

test("returns undefined when not in a git repo", () => {
const result = checkGitState({ runGit: () => fail() });
expect(result).toBeUndefined();
});

test("returns undefined when no upstream is configured", () => {
const result = checkGitState({
runGit: (args) => {
if (args[0] === "rev-parse") {
return ok("main");
}
if (args[0] === "rev-list") {
return fail();
}
throw new Error(`unexpected: ${args.join(" ")}`);
},
});
expect(result).toBeUndefined();
});