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
10 changes: 9 additions & 1 deletion electron/ipc/agents.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import type { AgentBackend } from '../mcp/agent-backends.js';

const execFileAsync = promisify(execFile);

interface AgentDef {
export interface AgentDef {
id: string;
name: string;
command: string;
Expand Down Expand Up @@ -99,6 +100,13 @@ export function getSkipPermissionsArgs(command: string): string[] {
return agent ? [...agent.skip_permissions_args] : [];
}

export function getAgentByBackend(backend: AgentBackend): AgentDef {
const id = backend === 'claude' ? 'claude-code' : backend;
const agent = DEFAULT_AGENTS.find((candidate) => candidate.id === id);
if (!agent) throw new Error(`Missing built-in agent definition for backend: ${backend}`);
return { ...agent, args: [...agent.args] };
}

export async function listAgents(): Promise<AgentDef[]> {
const now = Date.now();
if (cachedAgents && now - cacheTime < AGENT_CACHE_TTL) {
Expand Down
5 changes: 4 additions & 1 deletion electron/ipc/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,7 +775,10 @@ export async function createWorktree(

ensureClaudeSandboxFiles(worktreePath, repoRoot);
ensureSandboxExcludes(worktreePath);
ensureSymlinkExcludes(worktreePath, createdSymlinks);
// `.claude` is a real directory, but missing entries are copied from the
// main worktree and required sandbox placeholders are created locally. Ignore
// only untracked contents; tracked `.claude` changes remain visible to Git.
ensureSymlinkExcludes(worktreePath, [...createdSymlinks, '.claude']);

return { path: worktreePath, branch: branchName };
}
Expand Down
14 changes: 14 additions & 0 deletions electron/mcp/agent-backends.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const AGENT_BACKENDS = [
'claude',
'codex',
'gemini',
'opencode',
'copilot',
'antigravity',
] as const;

export type AgentBackend = (typeof AGENT_BACKENDS)[number];

export function isAgentBackend(value: unknown): value is AgentBackend {
return typeof value === 'string' && AGENT_BACKENDS.some((backend) => backend === value);
}
14 changes: 14 additions & 0 deletions electron/mcp/agent-frame-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ export const READY_AGENT_FRAME_FIXTURES: AgentFrameFixture[] = [
'opus · /Users/brooksc/git/parallel-code/.worktrees/task-023-10-loading-states · ctx:24k/200k',
].join('\n'),
},
{
name: 'Claude empty prompt with rotating suggestion',
frame: [
'─'.repeat(80),
'❯ Try "how does <filepath> work?"',
'─'.repeat(80),
'⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents',
].join('\r'),
},
{
name: 'Claude empty insert mode at fresh prompt',
frame: [
Expand Down Expand Up @@ -44,6 +53,11 @@ export const READY_AGENT_FRAME_FIXTURES: AgentFrameFixture[] = [
'gpt-5.5 default · /Users/brooksc/git/parallel-code/.worktrees/task-028-unit-tests',
].join('\n'),
},
{
name: 'Codex prompt with redraw-damaged escape sequences',
frame:
'13;15H s qg se q MCP server q q›Summarize recent commitsgpt-5.5 medium · ~/repo/.worktrees/task/example q',
},
{
name: 'Gemini typed-message prompt',
frame: [
Expand Down
1 change: 1 addition & 0 deletions electron/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export class MCPClient {
coordinatorTaskId?: string;
skipPermissions?: boolean;
baseBranch?: string;
backend?: string;
}): Promise<ApiTaskDetail> {
return this.request<ApiTaskDetail>('POST', '/api/tasks', opts);
}
Expand Down
63 changes: 60 additions & 3 deletions electron/mcp/coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ vi.mock('./prompt-detect.js', () => ({
.slice(-1000)
.split(/\r\n?|\n/)
.some((line) =>
/(?:^|\s)[❯›]\s*$|^\s*--\s*INSERT\s*--\s*$|^\s*>\s*(?:Type your message|$)/i.test(
/(?:^|\s)[❯›]\s*$|^\s*❯\s*Try\s+["“]|(?:^|\s|q)›[^\r\n›]*?gpt-[\w.-]+\s+\w+\s+·\s+~?\/|^\s*--\s*INSERT\s*--\s*$|^\s*>\s*(?:Type your message|$)/i.test(
line.trim(),
),
);
Expand All @@ -131,7 +131,7 @@ vi.mock('./prompt-detect.js', () => ({
return tail
.split(/\r\n?|\n/)
.some((line) =>
/(?:^|\s)[❯›]\s*$|^\s*--\s*INSERT\s*--\s*$|^\s*>\s*(?:Type your message|$)/i.test(
/(?:^|\s)[❯›]\s*$|^\s*❯\s*Try\s+["“]|(?:^|\s|q)›[^\r\n›]*?gpt-[\w.-]+\s+\w+\s+·\s+~?\/|^\s*--\s*INSERT\s*--\s*$|^\s*>\s*(?:Type your message|$)/i.test(
line.trim(),
),
);
Expand Down Expand Up @@ -181,7 +181,7 @@ vi.mock('../log.js', () => ({

// Import after mocks
const { Coordinator } = await import('./coordinator.js');
const { removePreambleBlock } = await import('./preamble.js');
const { normalizePreambleFileContent, removePreambleBlock } = await import('./preamble.js');

// --- helpers ---
function getExitHandler(): (agentId: string, data: unknown) => void {
Expand Down Expand Up @@ -1987,6 +1987,45 @@ describe('Coordinator sub-agent spawn settings', () => {
);
});

it('uses the requested backend instead of the coordinator command', async () => {
coordinator.setCoordinatorSpawnDefaults('coord-1', 'claude', ['--model', 'opus']);
await coordinator.createTask({
name: 'test',
prompt: 'do',
coordinatorTaskId: 'coord-1',
backend: 'codex',
});
expect(mockSpawnAgent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ command: 'codex', args: expect.not.arrayContaining(['opus']) }),
);
});

it('maps the antigravity backend to the agy command', async () => {
await coordinator.createTask({
name: 'test',
prompt: 'do',
coordinatorTaskId: 'coord-1',
backend: 'antigravity',
});
expect(mockSpawnAgent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ command: 'agy' }),
);
});

it('rejects unsupported backends before creating a worktree', async () => {
await expect(
coordinator.createTask({
name: 'test',
prompt: 'do',
coordinatorTaskId: 'coord-1',
backend: 'unknown',
}),
).rejects.toThrow('Unsupported agent backend');
expect(mockCreateBackendTask).not.toHaveBeenCalled();
});

it('inherits coordinator base args (e.g. --model)', async () => {
coordinator.setCoordinatorSpawnDefaults('coord-1', 'claude', ['--model', 'claude-opus-4-7']);
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
Expand Down Expand Up @@ -5601,6 +5640,24 @@ describe('Coordinator removePreambleBlock', () => {
});
});

describe('normalizePreambleFileContent', () => {
const BLOCK = '<sub-task-mode>\nrules\n</sub-task-mode>';

it('normalizes a committed AGENTS.md preamble on both sides of landing comparisons', () => {
expect(normalizePreambleFileContent('AGENTS.md', BLOCK)).toBe('');
expect(normalizePreambleFileContent('AGENTS.md', `${BLOCK}\n\n# User change`)).toBe(
'# User change',
);
});

it('normalizes injected Claude settings while preserving user settings', () => {
const content = JSON.stringify({ model: 'opus', systemPrompt: BLOCK });
expect(normalizePreambleFileContent('.claude/settings.local.json', content)).toBe(
JSON.stringify({ model: 'opus' }, null, 2),
);
});
});

// ─── getTaskDiff — preamble-bearing files (#34) ───────────────────────────────

describe('Coordinator getTaskDiff — preamble-bearing files', () => {
Expand Down
20 changes: 16 additions & 4 deletions electron/mcp/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import {
const execAsync = promisify(execFile);
import type { BrowserWindow } from 'electron';
import { createTask as createBackendTask, deleteTask } from '../ipc/tasks.js';
import { getSkipPermissionsArgs } from '../ipc/agents.js';
import { getAgentByBackend, getSkipPermissionsArgs } from '../ipc/agents.js';
import { isAgentBackend } from './agent-backends.js';
import {
spawnAgent,
writeToAgent,
Expand Down Expand Up @@ -792,6 +793,7 @@ export class Coordinator {
agentArgs?: string[];
skipPermissions?: boolean;
baseBranch?: string;
backend?: string;
}): Promise<CoordinatedTask> {
const coordinatorId =
opts.coordinatorTaskId !== REST_COORDINATOR_SENTINEL
Expand Down Expand Up @@ -820,6 +822,16 @@ export class Coordinator {
if (baseBranch !== undefined) {
validateBranchName(baseBranch, 'baseBranch');
}
const backend = opts.backend;
if (backend && !isAgentBackend(backend)) {
throw new Error(`Unsupported agent backend: ${backend}`);
}
const requestedAgent =
backend && isAgentBackend(backend) ? getAgentByBackend(backend) : undefined;
const selectedAgentCommand =
requestedAgent?.command ?? opts.agentCommand ?? coordinatorState.spawnDefaults.command;
const selectedAgentArgs =
requestedAgent?.args ?? opts.agentArgs ?? coordinatorState.spawnDefaults.args;

// Create worktree + branch via existing backend
const result = await createBackendTask(
Expand Down Expand Up @@ -878,7 +890,7 @@ export class Coordinator {
// Spawn the agent process
if (!this.win) throw new Error('No window set on coordinator');

const agentCmd = (opts.agentCommand ?? coordinatorState.spawnDefaults.command).toLowerCase();
const agentCmd = selectedAgentCommand.toLowerCase();
const preamble = `<sub-task-mode>\nThese rules override all skills and hooks:\n- When your work is complete, commit your changes and call the \`land_self\` MCP tool with the verification checks you ran. A successful \`land_self\` call is the finish line — do NOT call \`signal_done\` afterward, use finishing-a-development-branch, or offer merge/PR options.\n- Use \`signal_done\` only if the coordinator explicitly asks for manual review instead of self-landing.\n- Asking questions is fine when requirements are unclear or an action is risky.\n</sub-task-mode>`;
// Declared here so the catch block can restore preamble files on failure.
let preambleFilePath: string | undefined;
Expand Down Expand Up @@ -986,8 +998,8 @@ export class Coordinator {
task.mcpConfigPath = configPath;
}

const agentCommand = opts.agentCommand ?? coordinatorState.spawnDefaults.command;
const agentArgs = opts.agentArgs ?? coordinatorState.spawnDefaults.args;
const agentCommand = selectedAgentCommand;
const agentArgs = selectedAgentArgs;
const baseArgs = [
...agentArgs,
...(coordinatorState.propagateSkipPermissions ? getSkipPermissionsArgs(agentCommand) : []),
Expand Down
12 changes: 12 additions & 0 deletions electron/mcp/mcp-tool-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@ describe('selectTools — role-based tool list', () => {
expect(properties?.prompt?.type).toBe('string');
});

it('create_task exposes the supported agent backends', () => {
const createTask = COORDINATOR_TOOLS.find((tool) => tool.name === 'create_task');
const properties = createTask?.inputSchema.properties as
| Record<string, { type?: string; enum?: string[] }>
| undefined;
expect(properties?.backend).toEqual({
type: 'string',
enum: ['claude', 'codex', 'gemini', 'opencode', 'copilot', 'antigravity'],
description: expect.stringContaining('Defaults to the coordinator backend'),
});
});

it('send_prompt requires a prompt', () => {
const sendPrompt = COORDINATOR_TOOLS.find((tool) => tool.name === 'send_prompt');
expect(sendPrompt?.inputSchema.required).toContain('prompt');
Expand Down
6 changes: 6 additions & 0 deletions electron/mcp/mcp-tool-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ export const COORDINATOR_TOOLS: ToolDef[] = [
description:
'Git branch to base the worktree on. Defaults to the coordinator task branch. Only set this when deliberately overriding that default.',
},
backend: {
type: 'string',
enum: ['claude', 'codex', 'gemini', 'opencode', 'copilot', 'antigravity'],
description:
'AI agent backend for this task. Defaults to the coordinator backend when omitted.',
},
},
required: ['name', 'prompt'],
},
Expand Down
50 changes: 27 additions & 23 deletions electron/mcp/preamble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,25 @@ export function removePreambleBlock(content: string): string {
return `${before}\n\n${after}`;
}

export function normalizePreambleFileContent(
filename: string,
content: string,
removePreamble: (value: string) => string = removePreambleBlock,
): string | null {
if (filename !== '.claude/settings.local.json') return removePreamble(content);
try {
const settings = JSON.parse(content) as Record<string, unknown>;
if (typeof settings.systemPrompt === 'string') {
const stripped = removePreamble(settings.systemPrompt);
if (stripped.trim()) settings.systemPrompt = stripped;
else delete settings.systemPrompt;
}
return Object.keys(settings).length === 0 ? '' : JSON.stringify(settings, null, 2);
} catch {
return null;
}
}

/** Return the set of filenames (relative to worktreePath) that contain a preamble block. */
export async function detectPreambleFiles(worktreePath: string): Promise<Set<string>> {
const result = new Set<string>();
Expand Down Expand Up @@ -91,43 +110,28 @@ export async function buildNormalizedPreambleFileDiff(
return '';
}

let normalizedContent: string;
if (filename === '.claude/settings.local.json') {
try {
const s = JSON.parse(worktreeContent) as Record<string, unknown>;
if (typeof s.systemPrompt === 'string') {
const stripped = removePreamble(s.systemPrompt);
if (stripped.trim()) {
s.systemPrompt = stripped;
} else {
delete s.systemPrompt;
}
}
normalizedContent = Object.keys(s).length === 0 ? '' : JSON.stringify(s, null, 2);
} catch {
return '';
}
} else {
normalizedContent = removePreamble(worktreeContent);
}
const normalizedContent = normalizePreambleFileContent(filename, worktreeContent, removePreamble);
if (normalizedContent === null) return '';

let baseContent = '';
try {
const { stdout } = await execAsync('git', ['show', `${baseSha}:${filename}`], {
const result = await execAsync('git', ['show', `${baseSha}:${filename}`], {
cwd: worktreePath,
});
baseContent = stdout;
const stdout = typeof result === 'string' ? result : result.stdout;
baseContent = typeof stdout === 'string' ? stdout : '';
} catch {
baseContent = '';
}

if (normalizedContent === baseContent) return '';
const normalizedBaseContent = normalizePreambleFileContent(filename, baseContent, removePreamble);
if (normalizedBaseContent === null || normalizedContent === normalizedBaseContent) return '';

const id = randomUUID();
const tmpBase = join(os.tmpdir(), `parallel-code-base-${id}`);
const tmpNorm = join(os.tmpdir(), `parallel-code-norm-${id}`);
try {
writeFileSync(tmpBase, baseContent);
writeFileSync(tmpBase, normalizedBaseContent);
writeFileSync(tmpNorm, normalizedContent);
let diffOut = '';
try {
Expand Down
5 changes: 5 additions & 0 deletions electron/mcp/prompt-detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,13 @@ export const PROMPT_PATTERNS: RegExp[] = [
*/
export const AGENT_READY_TAIL_PATTERNS: RegExp[] = [
/^\s*❯\s*$/, // Claude Code
/^\s*❯\s*Try\s+["“]/i, // Claude Code empty prompt with rotating suggestion
/^\s*--\s*INSERT\s*--(?:$|\s|[^\w].*$)/i, // Claude Code vim-style input mode
/^\s*›\s*$/, // Codex CLI
// Codex redraws can lose CSI escape introducers when output chunks split an
// escape sequence. stripAnsi then leaves `q` separators and cursor-position
// fragments, collapsing the input placeholder and footer onto one line.
/(?:^|\s|q)›[^\r\n›]*?gpt-[\w.-]+\s+\w+\s+·\s+~?\//i,
/^\s*>\s*(?:Type your message|$)/i, // Gemini CLI
];

Expand Down
Loading
Loading