Skip to content
Open
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: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ npm install -g @anthropic-ai/claude-code

# Or OpenCode
npm i -g opencode-ai

# Or GitHub Copilot CLI
npm install -g @github/copilot
```

### Supported Adapters
Expand All @@ -58,6 +61,7 @@ npm i -g opencode-ai
| Claude Code | Complete | `claude-code-acp` |
| OpenCode | Complete | `opencode acp` |
| Gemini CLI | Under Development | `gemini --experimental-acp` |
| GitHub Copilot CLI | Complete | `copilot --acp` |

All adapters communicate via ACP (Agent Client Protocol).

Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/adapters/acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export abstract class AcpAdapter {
private currentOptions?: AcpAdapterOptions;
private readonly permissionCache = new Map<string, string>();
private readonly toolNameCache = new Map<string, string>();
private cleanupPromise?: Promise<void>;

/**
* Check if the adapter command is available.
Expand All @@ -102,6 +103,14 @@ export abstract class AcpAdapter {
this.handler = handler;
this.currentOptions = options;

// Wait for any cleanup from a previous run to finish before spawning a
// new subprocess/connection. Without this, a late cleanup() from the
// prior iteration can null out this.connection mid-run, causing
// "undefined is not an object (evaluating 'this.connection.newSession')".
if (this.cleanupPromise) {
await this.cleanupPromise;
}

try {
// Spawn the ACP subprocess
this.process = spawn([this.command, ...this.args], {
Expand Down Expand Up @@ -375,6 +384,16 @@ export abstract class AcpAdapter {
}

private async cleanup(): Promise<void> {
if (this.cleanupPromise) {
return this.cleanupPromise;
}
this.cleanupPromise = this.doCleanup().finally(() => {
this.cleanupPromise = undefined;
});
return this.cleanupPromise;
}

private async doCleanup(): Promise<void> {
if (this.process) {
this.process.kill();
await this.process.exited;
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/adapters/copilot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { AcpAdapter, type ResumeCommand } from "./acp";

/**
* GitHub Copilot CLI ACP adapter.
* Uses `copilot --acp` command.
*
* Sets the session mode to Autopilot, which is described by the agent as
* "Autonomous mode that enables allow-all and runs until task completion
* without user interaction." Permission requests still arrive over ACP and
* are handled by the base adapter's yolo/callback flow.
*/

const AUTOPILOT_MODE_ID =
"https://agentclientprotocol.com/protocol/session-modes#autopilot";

export class CopilotAcpAdapter extends AcpAdapter {
readonly name = "copilot";
readonly command = "copilot";
readonly args = ["--acp"];

getResumeCommand(sessionId: string): ResumeCommand {
return { command: "copilot", args: [`--resume=${sessionId}`] };
}

protected override getPreferredModeId(): string | null {
return AUTOPILOT_MODE_ID;
}
}
35 changes: 35 additions & 0 deletions packages/cli/src/adapters/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { describe, expect, test } from "bun:test";
import { ClaudeAcpAdapter } from "./claude";
import { CopilotAcpAdapter } from "./copilot";
import { GeminiAcpAdapter } from "./gemini";
import { getAdapter } from "./index";
import { OpenCodeAcpAdapter } from "./opencode";
Expand Down Expand Up @@ -35,6 +36,15 @@ describe("getAdapter factory", () => {
expect(adapter.name).toBe("gemini");
expect(adapter.command).toBe("gemini");
});

test("returns CopilotAcpAdapter for 'copilot' type", () => {
const adapter = getAdapter("copilot");

expect(adapter).toBeInstanceOf(CopilotAcpAdapter);
expect(adapter.name).toBe("copilot");
expect(adapter.command).toBe("copilot");
expect(adapter.args).toEqual(["--acp"]);
});
});

describe("ACP adapter interface", () => {
Expand Down Expand Up @@ -70,6 +80,27 @@ describe("getAdapter factory", () => {
expect(typeof adapter.run).toBe("function");
expect(typeof adapter.cancel).toBe("function");
});

test("copilot adapter has required interface properties", () => {
const adapter = getAdapter("copilot");

expect(adapter).toHaveProperty("name");
expect(adapter).toHaveProperty("command");
expect(adapter).toHaveProperty("args");
expect(typeof adapter.isAvailable).toBe("function");
expect(typeof adapter.run).toBe("function");
expect(typeof adapter.cancel).toBe("function");
});

test("copilot adapter returns equals-form resume command", () => {
const adapter = getAdapter("copilot");
const resume = adapter.getResumeCommand("abc123");

expect(resume).toEqual({
command: "copilot",
args: ["--resume=abc123"],
});
});
});

describe("error handling", () => {
Expand Down Expand Up @@ -101,10 +132,14 @@ describe("getAdapter factory", () => {
const claude = getAdapter("claude");
const opencode = getAdapter("opencode");
const gemini = getAdapter("gemini");
const copilot = getAdapter("copilot");

expect(claude.constructor).not.toBe(opencode.constructor);
expect(claude.constructor).not.toBe(gemini.constructor);
expect(claude.constructor).not.toBe(copilot.constructor);
expect(gemini.constructor).not.toBe(copilot.constructor);
expect(claude.name).not.toBe(opencode.name);
expect(claude.name).not.toBe(copilot.name);
});
});
});
2 changes: 2 additions & 0 deletions packages/cli/src/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { AdapterType } from "#config/schema";
import type { AcpAdapter } from "./acp";
import { ClaudeAcpAdapter } from "./claude";
import { CopilotAcpAdapter } from "./copilot";
import { GeminiAcpAdapter } from "./gemini";
import { OpenCodeAcpAdapter } from "./opencode";

const adapters: Record<AdapterType, () => AcpAdapter> = {
claude: () => new ClaudeAcpAdapter(),
opencode: () => new OpenCodeAcpAdapter(),
gemini: () => new GeminiAcpAdapter(),
copilot: () => new CopilotAcpAdapter(),
};

export function getAdapter(type: AdapterType): AcpAdapter {
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const initSource = readFileSync(INIT_SOURCE_PATH, "utf-8");
// Regex patterns for source code validation (defined at top level for performance)
const CHOICE_3_PATTERN = /choice\s*===\s*["']3["']/;
const RETURN_GEMINI_PATTERN = /return\s*["']gemini["']/;
const CHOICE_4_PATTERN = /choice\s*===\s*["']4["']/;
const RETURN_COPILOT_PATTERN = /return\s*["']copilot["']/;

describe("init command", () => {
describe("command metadata", () => {
Expand Down Expand Up @@ -90,16 +92,27 @@ describe("init command", () => {
expect(initSource).toContain("3. gemini");
});

test("selectAdapter lists all three adapters", () => {
test("selectAdapter includes copilot option", () => {
expect(initSource).toContain("copilot");
expect(initSource).toContain("4. copilot");
});

test("selectAdapter lists all four adapters", () => {
expect(initSource).toContain("claude (Claude Code CLI)");
expect(initSource).toContain("opencode (OpenCode CLI)");
expect(initSource).toContain("gemini (Gemini CLI)");
expect(initSource).toContain("copilot (GitHub Copilot CLI)");
});

test("selectAdapter returns gemini for choice 3", () => {
// Verify the choice mapping logic exists in source
expect(initSource).toMatch(CHOICE_3_PATTERN);
expect(initSource).toMatch(RETURN_GEMINI_PATTERN);
});

test("selectAdapter returns copilot for choice 4", () => {
expect(initSource).toMatch(CHOICE_4_PATTERN);
expect(initSource).toMatch(RETURN_COPILOT_PATTERN);
});
});
});
4 changes: 4 additions & 0 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ async function selectAdapter(): Promise<AdapterType> {
console.log(" 1. claude (Claude Code CLI)");
console.log(" 2. opencode (OpenCode CLI)");
console.log(" 3. gemini (Gemini CLI)");
console.log(" 4. copilot (GitHub Copilot CLI)");

const choice = await prompt("Choice", "1");
if (choice === "2") {
Expand All @@ -83,6 +84,9 @@ async function selectAdapter(): Promise<AdapterType> {
if (choice === "3") {
return "gemini";
}
if (choice === "4") {
return "copilot";
}
return "claude";
}

Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ describe("AdapterType", () => {
}
});

/**
* Tests that 'copilot' is a valid adapter type.
* Copilot uses `copilot --acp` to speak the Agent Client Protocol.
*/
test("accepts 'copilot' as valid adapter", () => {
const result = AdapterType.safeParse("copilot");
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe("copilot");
}
});

/**
* Tests that unknown adapter types are rejected.
* This prevents typos and invalid configurations from causing issues.
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/config/schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";

export const AdapterType = z.enum(["claude", "gemini", "opencode"]);
export const AdapterType = z.enum(["claude", "gemini", "opencode", "copilot"]);
export type AdapterType = z.infer<typeof AdapterType>;

export const HooksSchema = z.object({
Expand Down
5 changes: 3 additions & 2 deletions packages/docs/src/content/docs/announcements/acp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ Ralph's agent communication layer has been rebuilt around the **Agent Client Pro

## Expanded agent support

Three adapters are available, all using ACP:
Four adapters are available, all using ACP:

| Adapter | Command |
|---------|---------|
| Claude Code | `claude-code-acp` |
| OpenCode | `opencode acp` |
| Gemini (experimental) | `gemini --experimental-acp` |
| GitHub Copilot CLI | `copilot --acp` |

Because adapters share a common protocol, adding support for new agents is now straightforward.

Expand All @@ -41,7 +42,7 @@ ralph run --yolo # auto-approve all permissions

## Session continuity

Each run now has a session ID. If a run is interrupted, Claude Code and OpenCode sessions can be resumed — either by ralph itself on the next iteration, or by opening the session directly in the native CLI tool from the TUI.
Each run now has a session ID. If a run is interrupted, Claude Code, OpenCode, and GitHub Copilot CLI sessions can be resumed — either by ralph itself on the next iteration, or by opening the session directly in the native CLI tool from the TUI.

---

Expand Down
4 changes: 2 additions & 2 deletions packages/docs/src/content/docs/guides/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ ralph is distributed as an npm package and works on macOS, Linux, and Windows (v
Before installing ralph, ensure you have:

- **Node.js 18+** — ralph uses modern JavaScript features
- **An AI CLI tool** — Claude Code or OpenCode currently supported.
- **An AI CLI tool** — Claude Code, OpenCode, Gemini CLI, or GitHub Copilot CLI currently supported.
- **VCS** — For state persistence between iterations use git, jj or your preferred version control system.

<Aside type="note">
Expand Down Expand Up @@ -142,7 +142,7 @@ export PATH="$PATH:$(npm config get prefix)/bin"
Make sure your AI tool is installed and accessible:

```bash
which claude # or: which opencode
which claude # or: which opencode, which gemini, which copilot
```

### Permission errors
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/guides/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Aside, Steps } from '@astrojs/starlight/components';
Let's run your first ralph loop. We'll add tests to a simple project—the kind of tedious, repetitive work that ralph excels at.

<Aside type="tip">
This guide assumes you've [installed ralph](/guides/installation/) and have an AI tool like Claude Code or Opencode available.
This guide assumes you've [installed ralph](/guides/installation/) and have an AI tool like Claude Code, Opencode, or GitHub Copilot CLI available.
</Aside>

## The 5-Minute Version
Expand Down
9 changes: 5 additions & 4 deletions packages/docs/src/content/docs/reference/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ ralph is designed to be simple, composable, and tool-agnostic. This document exp
┌─────────────────────────────────────────────────────────────────┐
│ AI Tool (External) │
│ │
│ ┌────────┐ ┌────────┐ ┌────────┐
│ │ Claude │ │OpenCode│ │ Gemini │
│ │ Code │ │ │ │ (dev) │
│ └────────┘ └────────┘ └────────┘
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ │ Claude │ │OpenCode│ │ Gemini │ │Copilot │
│ │ Code │ │ │ │ (dev) │
│ └────────┘ └────────┘ └────────┘ └────────┘
│ │
└─────────────────────────────────────────────────────────────────┘
```
Expand Down Expand Up @@ -105,6 +105,7 @@ Each supported tool has an adapter:
- `ClaudeAdapter` — for Claude Code
- `OpenCodeAdapter` — for OpenCode
- `GeminiAdapter` — for Gemini (under development)
- `CopilotAdapter` — for GitHub Copilot CLI (`copilot --acp`)

### Completion Checker

Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/usage/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ project/

**Interactive prompts:**
- Plans directory name (default: `.plans`)
- AI CLI adapter selection (`claude`, `opencode`, or `gemini`)
- AI CLI adapter selection (`claude`, `opencode`, `gemini`, or `copilot`)

**Example:**

Expand Down
3 changes: 2 additions & 1 deletion packages/docs/src/content/docs/usage/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ ralph init
### Full Configuration Example

```toml
# AI adapter: claude, opencode, or gemini
# AI adapter: claude, opencode, gemini, or copilot
adapter = "claude"

# Directory for plans, prompts, and progress
Expand Down Expand Up @@ -68,6 +68,7 @@ The AI CLI tool to use. Options:
| `claude` | Complete | Anthropic's Claude Code CLI |
| `opencode` | Complete | Open-source AI coding assistant |
| `gemini` | Under Development | Google's Gemini CLI |
| `copilot` | Complete | GitHub Copilot CLI (`copilot --acp`) |

### Plans Directory

Expand Down