Skip to content
6 changes: 3 additions & 3 deletions packages/agent/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,9 @@ export const runCommand = async (
options.agentTool || workflowManager.defaults?.tool || 'claude';

// Temporarily ACP usage decision during ACP migration process:
// force Claude and OpenCode to always use ACP mode
const useACPMode =
tool.toLowerCase() === 'claude' || tool.toLowerCase() === 'opencode';
// force Claude, Copilot, and OpenCode to always use ACP mode
const acpEnabledTools = ['claude', 'copilot', 'opencode'];
const useACPMode = acpEnabledTools.includes(tool.toLowerCase());

if (useACPMode) {
console.log(colors.cyan('\n🔗 ACP Mode enabled'));
Expand Down
140 changes: 133 additions & 7 deletions packages/agent/src/lib/acp-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ClientSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
type ContentBlock,
} from '@agentclientprotocol/sdk';
import colors from 'ansi-colors';
import { existsSync, readFileSync } from 'node:fs';
Expand Down Expand Up @@ -51,6 +52,11 @@ function getACPSpawnCommand(tool: string): { command: string; args: string[] } {
command: 'npx',
args: ['-y', '@zed-industries/claude-code-acp'],
};
case 'copilot':
return {
command: 'copilot',
args: ['--acp'],
};
case 'opencode':
return {
command: 'opencode',
Expand Down Expand Up @@ -207,9 +213,26 @@ export class ACPRunner {
/**
* Send a prompt to the current session
* Maps to the ACP session/prompt method
*
* Supports text, images (base64 or file:// URIs), and embedded resources.
*/
async sendPrompt(
prompt: string
prompt: string,
options?: {
/** Image attachments (base64 data or file:// URIs) */
images?: Array<{
data: string;
mimeType: string;
uri?: string;
}>;
/** Embedded text or blob resources */
resources?: Array<{
uri: string;
mimeType?: string;
text?: string;
blob?: string;
}>;
}
): Promise<{ stopReason: string; response: string }> {
if (!this.isConnectionInitialized || !this.connection) {
throw new Error(
Expand All @@ -229,14 +252,54 @@ export class ACPRunner {
// Start capturing agent messages
this.client.startCapturing();

// Build prompt content array
const promptContent: ContentBlock[] = [
{
type: 'text',
text: prompt,
},
];

// Add image attachments if provided
if (options?.images) {
for (const image of options.images) {
promptContent.push({
type: 'image',
data: image.data,
mimeType: image.mimeType,
uri: image.uri,
});
}
}

// Add embedded resources if provided
if (options?.resources) {
for (const resource of options.resources) {
if (resource.text !== undefined) {
promptContent.push({
type: 'resource',
resource: {
uri: resource.uri,
mimeType: resource.mimeType,
text: resource.text,
},
});
} else if (resource.blob !== undefined) {
promptContent.push({
type: 'resource',
resource: {
uri: resource.uri,
mimeType: resource.mimeType,
blob: resource.blob,
},
});
}
}
}

const promptResult = await this.connection.prompt({
sessionId: this.sessionId,
prompt: [
{
type: 'text',
text: prompt,
},
],
prompt: promptContent,
});

// Stop capturing and get the accumulated response
Expand All @@ -252,6 +315,69 @@ export class ACPRunner {
}
}

/**
* Set the model for the current session
* Maps to the ACP session/set_model method (experimental)
*
* Allows changing the AI model used for subsequent prompts in the session.
*/
async setModel(modelId: string): Promise<void> {
if (!this.isConnectionInitialized || !this.connection) {
throw new Error(
'Connection not initialized. Call initializeConnection() first.'
);
}

if (!this.isSessionCreated || !this.sessionId) {
throw new Error('No active session. Call createSession() first.');
}

try {
await this.connection.unstable_setSessionModel({
sessionId: this.sessionId,
modelId,
});

console.log(colors.gray(`🔄 Model changed to: ${modelId}`));
} catch (error) {
throw new Error(
`Failed to set model: ${error instanceof Error ? error.message : String(error)}`
);
}
}

/**
* Cancel an ongoing prompt operation
* Maps to the ACP session/cancel notification
*
* Aborts any running language model requests and tool calls.
*/
async cancelPrompt(): Promise<void> {
if (!this.isConnectionInitialized || !this.connection) {
throw new Error(
'Connection not initialized. Call initializeConnection() first.'
);
}

if (!this.isSessionCreated || !this.sessionId) {
throw new Error('No active session. Call createSession() first.');
}

try {
await this.connection.cancel({
sessionId: this.sessionId,
});

console.log(
colors.yellow(`⚠️ Prompt cancelled for session: ${this.sessionId}`)
);
} catch (error) {
throw new Error(
`Failed to cancel prompt: ${error instanceof Error ? error.message : String(error)}`
);
}
}

/**
* Run a single workflow step using the ACP session
*/
Expand Down
177 changes: 177 additions & 0 deletions packages/agent/src/lib/agents/copilot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import {
existsSync,
copyFileSync,
cpSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import colors from 'ansi-colors';
import { AgentCredentialFile } from './types.js';
import { BaseAgent } from './base.js';

export class CopilotAgent extends BaseAgent {
name = 'Copilot';
binary = 'copilot';

constructor(version: string = 'latest', model?: string) {
super(version, model);
}

getInstallCommand(): string {
const packageSpec = `@github/copilot@${this.version}`;
return `npm install -g ${packageSpec}`;
}

getRequiredCredentials(): AgentCredentialFile[] {
return [
{
path: '/.copilot',
description: 'Copilot configuration directory',
required: true,
},
];
}

async copyCredentials(targetDir: string): Promise<void> {
console.log(colors.bold(`\nCopying ${this.name} credentials`));

const targetCopilotDir = join(targetDir, '.copilot');
// Ensure .copilot directory exists
this.ensureDirectory(targetCopilotDir);

const credentials = this.getRequiredCredentials();
for (const cred of credentials) {
if (existsSync(cred.path)) {
// Copy the entire .copilot directory
cpSync(cred.path, targetCopilotDir, { recursive: true });
console.log(colors.gray('├── Copied: ') + colors.cyan(cred.path));
}
}

console.log(colors.green(`✓ ${this.name} credentials copied successfully`));
}

async configureMCP(
name: string,
commandOrUrl: string,
transport: string,
envs: string[],
headers: string[]
): Promise<void> {
const configPath = join(homedir(), '.copilot', 'mcp-config.json');

// Read existing config or initialize with empty mcpServers
let config: { mcpServers: Record<string, any> } = { mcpServers: {} };
if (existsSync(configPath)) {
try {
const content = readFileSync(configPath, 'utf-8');
config = JSON.parse(content);
if (!config.mcpServers) {
config.mcpServers = {};
}
} catch (error: any) {
console.log(
colors.yellow(
`Warning: Could not parse existing config: ${error.message}`
)
);
config = { mcpServers: {} };
}
}

// Parse environment variables (KEY=VALUE format)
const env: Record<string, string> = {};
envs.forEach(envVar => {
const match = envVar.match(/^(\w+)=(.*)$/);
if (match) {
env[match[1]] = match[2];
} else {
console.log(
colors.yellow(
`Warning: Invalid environment variable format: ${envVar} (expected KEY=VALUE)`
)
);
}
});

// Parse headers (KEY: VALUE format)
const headersObj: Record<string, string> = {};
headers.forEach(header => {
const match = header.match(/^([\w-]+)\s*:\s*(.+)$/);
if (match) {
headersObj[match[1]] = match[2];
} else {
console.log(
colors.yellow(
`Warning: Invalid header format: ${header} (expected "KEY: VALUE")`
)
);
}
});

const serverConfig: any = {
tools: ['*'],
};

serverConfig.type = transport;

if (transport === 'stdio') {
const parts = commandOrUrl.split(' ');
serverConfig.command = parts[0];
// Copilot CLI requires 'args' array even if empty
serverConfig.args = parts.length > 1 ? parts.slice(1) : [];
if (Object.keys(env).length > 0) {
serverConfig.env = env;
}
} else if (['http', 'sse'].includes(transport)) {
serverConfig.url = commandOrUrl;
if (Object.keys(headersObj).length > 0) {
serverConfig.headers = headersObj;
}
if (Object.keys(env).length > 0) {
serverConfig.env = env;
}
} else {
throw new Error(
`Unsupported transport type: ${transport}. Use 'stdio', 'http', or 'sse'.`
);
}

// Add or update the server configuration
config.mcpServers[name] = serverConfig;

// Ensure the .copilot directory exists
this.ensureDirectory(join(homedir(), '.copilot'));

// Write the configuration back to disk
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');

console.log(
colors.green(`✓ MCP server "${name}" configured for ${this.name}`)
);
}

toolArguments(): string[] {
const args = ['--allow-all-tools', '--silent'];
if (this.model) {
args.push('--model', this.model);
}
args.push('-p');
return args;
}

toolInteractiveArguments(
precontext: string,
initialPrompt?: string
): string[] {
let prompt = precontext;

if (initialPrompt) {
prompt += `\n\nInitial User Prompt:\n\n${initialPrompt}`;
}

return ['-p', prompt];
}
}
4 changes: 4 additions & 0 deletions packages/agent/src/lib/agents/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Agent } from './types.js';
import { ClaudeAgent } from './claude.js';
import { CodexAgent } from './codex.js';
import { CopilotAgent } from './copilot.js';
import { CursorAgent } from './cursor.js';
import { GeminiAgent } from './gemini.js';
import { OpenCodeAgent } from './opencode.js';
Expand All @@ -10,6 +11,7 @@ import { AI_AGENT } from 'rover-core';
export * from './types.js';
export { ClaudeAgent } from './claude.js';
export { CodexAgent } from './codex.js';
export { CopilotAgent } from './copilot.js';
export { CursorAgent } from './cursor.js';
export { GeminiAgent } from './gemini.js';
export { OpenCodeAgent } from './opencode.js';
Expand All @@ -25,6 +27,8 @@ export function createAgent(
return new ClaudeAgent(version, model);
case 'codex':
return new CodexAgent(version, model);
case 'copilot':
return new CopilotAgent(version, model);
case 'cursor':
return new CursorAgent(version, model);
case 'gemini':
Expand Down
Loading