Skip to content

Commit b43a427

Browse files
brookscclaude
andcommitted
feat(coordinator): MCP orchestration engine, REST API hardening, and task model
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 326c9f5 commit b43a427

40 files changed

Lines changed: 14465 additions & 582 deletions

electron/ipc/agents.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { execFile } from 'child_process';
22
import { promisify } from 'util';
3+
import path from 'path';
34

45
const execFileAsync = promisify(execFile);
56

@@ -81,6 +82,12 @@ let cachedAgents: AgentDef[] | null = null;
8182
let cacheTime = 0;
8283
const AGENT_CACHE_TTL = 30_000;
8384

85+
export function getSkipPermissionsArgs(command: string): string[] {
86+
const base = path.basename(command);
87+
const agent = DEFAULT_AGENTS.find((a) => a.command === base || a.command === command);
88+
return agent ? agent.skip_permissions_args : [];
89+
}
90+
8491
export async function listAgents(): Promise<AgentDef[]> {
8592
const now = Date.now();
8693
if (cachedAgents && now - cacheTime < AGENT_CACHE_TTL) {

electron/ipc/channels.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,31 @@ export enum IPC {
137137

138138
// Logging
139139
LogFromRenderer = 'log_from_renderer',
140+
141+
// MCP / Coordinating agent
142+
SetCoordinatorModeEnabled = 'set_coordinator_mode_enabled',
143+
StartMCPServer = 'start_mcp_server',
144+
StopMCPServer = 'stop_mcp_server',
145+
GetMCPStatus = 'get_mcp_status',
146+
GetMCPLogs = 'get_mcp_logs',
147+
MCP_TaskCreated = 'mcp_task_created',
148+
MCP_TaskClosed = 'mcp_task_closed',
149+
MCP_TaskStateSync = 'mcp_task_state_sync',
150+
MCP_ControlChanged = 'mcp_control_changed',
151+
// Coordinator notifications (main → renderer)
152+
MCP_CoordinatorNotificationStaged = 'mcp_coordinator_notification_staged',
153+
MCP_CoordinatorNotificationCleared = 'mcp_coordinator_notification_cleared',
154+
MCP_CoordinatorOrphanedNotification = 'mcp_coordinator_orphaned_notification',
155+
// Coordinator lifecycle (renderer → main)
156+
MCP_CoordinatorRegistered = 'mcp_coordinator_registered',
157+
MCP_CoordinatorDeregistered = 'mcp_coordinator_deregistered',
158+
MCP_CoordinatorNotificationAck = 'mcp_coordinator_notification_ack',
159+
MCP_CoordinatorNotificationDropAck = 'mcp_coordinator_notification_drop_ack',
160+
MCP_CoordinatedTaskPromptDelivered = 'mcp_coordinated_task_prompt_delivered',
161+
MCP_CoordinatorRestageAfterUserSend = 'mcp_coordinator_restage_after_user_send',
162+
MCP_HydrateCoordinatedTask = 'mcp_hydrate_coordinated_task',
163+
MCP_TaskHydrated = 'mcp_task_hydrated',
164+
MCP_StaleUrlWarning = 'mcp_stale_url_warning',
165+
MCP_CoordinatedTaskClosed = 'mcp_coordinated_task_closed',
166+
MCP_TaskCleanupFailed = 'mcp_task_cleanup_failed',
140167
}

electron/ipc/docker-config.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* Layer 1 — Docker coordinator config (pure, no Docker required)
3+
*
4+
* Fast unit tests for the pure functions that generate MCP config for Docker coordinators.
5+
* No Docker, no network, no filesystem writes.
6+
*/
7+
8+
import { describe, expect, it } from 'vitest';
9+
import {
10+
buildCoordinatorMCPConfig,
11+
getDockerMcpServerDestPath,
12+
selectMcpJsonDir,
13+
} from './register.js';
14+
import { getMCPRemoteServerUrl } from '../mcp/config.js';
15+
16+
// ── MCP server URL ─────────────────────────────────────────────────────────────
17+
18+
describe('getMCPRemoteServerUrl — host resolution', () => {
19+
it('uses host.docker.internal on macOS Docker Desktop', () => {
20+
expect(getMCPRemoteServerUrl(3001, 'my-container', 'darwin')).toBe(
21+
'http://host.docker.internal:3001',
22+
);
23+
});
24+
25+
it('uses 127.0.0.1 on Linux (--network host makes localhost IS the host)', () => {
26+
// --add-host=host.docker.internal:host-gateway is incompatible with --network host on Linux.
27+
// With --network host the container shares the host network stack, so 127.0.0.1 IS the host.
28+
expect(getMCPRemoteServerUrl(3001, 'my-container', 'linux')).toBe('http://127.0.0.1:3001');
29+
});
30+
31+
it('uses 127.0.0.1 when no container name (non-Docker)', () => {
32+
expect(getMCPRemoteServerUrl(3001, undefined)).toBe('http://127.0.0.1:3001');
33+
});
34+
35+
it('uses 127.0.0.1 when container name is empty string', () => {
36+
expect(getMCPRemoteServerUrl(3001, '')).toBe('http://127.0.0.1:3001');
37+
});
38+
});
39+
40+
// ── .mcp.json placement ────────────────────────────────────────────────────────
41+
42+
describe('selectMcpJsonDir — .mcp.json placement', () => {
43+
it('places .mcp.json in worktreePath when provided', () => {
44+
expect(selectMcpJsonDir('/worktrees/coord-abc', '/project')).toBe('/worktrees/coord-abc');
45+
});
46+
47+
it('falls back to projectRoot when worktreePath is undefined', () => {
48+
expect(selectMcpJsonDir(undefined, '/project')).toBe('/project');
49+
});
50+
51+
it('worktreePath wins over projectRoot (Docker: container only mounts worktree)', () => {
52+
const worktreePath = '/Users/alice/repo/.worktrees/task/coord-abc123';
53+
const projectRoot = '/Users/alice/repo';
54+
const dir = selectMcpJsonDir(worktreePath, projectRoot);
55+
// .mcp.json must be inside the volume-mounted worktree, not the projectRoot (not mounted)
56+
expect(dir).toBe(worktreePath);
57+
expect(dir).not.toBe(projectRoot);
58+
});
59+
});
60+
61+
// ── copied mcp-server.cjs path ─────────────────────────────────────────────────
62+
63+
describe('getDockerMcpServerDestPath — copied mcp-server.cjs location', () => {
64+
it('places mcp-server.cjs in worktree .parallel-code dir', () => {
65+
const dest = getDockerMcpServerDestPath('/worktrees/coord', '/project');
66+
expect(dest).toBe('/worktrees/coord/.parallel-code/mcp-server.cjs');
67+
});
68+
69+
it('falls back to projectRoot when worktreePath is undefined', () => {
70+
const dest = getDockerMcpServerDestPath(undefined, '/project');
71+
expect(dest).toBe('/project/.parallel-code/mcp-server.cjs');
72+
});
73+
74+
it('dest is under the mounted worktree, not the unmounted projectRoot', () => {
75+
const worktreePath = '/home/user/repo/.worktrees/task/coord-abc123';
76+
const projectRoot = '/home/user/repo';
77+
const dest = getDockerMcpServerDestPath(worktreePath, projectRoot);
78+
// The container mounts worktreePath (not projectRoot), so the script must live there
79+
expect(dest.startsWith(worktreePath)).toBe(true);
80+
expect(dest.startsWith(projectRoot + '/.parallel-code')).toBe(false);
81+
});
82+
83+
it('filename is always mcp-server.cjs', () => {
84+
const dest = getDockerMcpServerDestPath('/worktrees/coord', '/project');
85+
expect(dest.endsWith('/mcp-server.cjs')).toBe(true);
86+
});
87+
});
88+
89+
// ── .mcp.json config content ───────────────────────────────────────────────────
90+
91+
describe('buildCoordinatorMCPConfig — config content', () => {
92+
const baseOpts = {
93+
mcpServerPath: '/worktrees/coord/.parallel-code/mcp-server.cjs',
94+
serverUrl: 'http://host.docker.internal:3001',
95+
token: 'test-token-abc',
96+
coordinatorTaskId: 'coord-task-1',
97+
};
98+
99+
it('has type:stdio and command:node', () => {
100+
const cfg = buildCoordinatorMCPConfig(baseOpts);
101+
const server = cfg.mcpServers['parallel-code'];
102+
expect(server.type).toBe('stdio');
103+
expect(server.command).toBe('node');
104+
});
105+
106+
it('args[0] is the mcp-server.cjs path (the copied worktree path, not host path)', () => {
107+
const cfg = buildCoordinatorMCPConfig(baseOpts);
108+
expect(cfg.mcpServers['parallel-code'].args[0]).toBe(baseOpts.mcpServerPath);
109+
});
110+
111+
it('args contain --url pointing to host.docker.internal', () => {
112+
const cfg = buildCoordinatorMCPConfig(baseOpts);
113+
const args = cfg.mcpServers['parallel-code'].args;
114+
const urlIdx = args.indexOf('--url');
115+
expect(urlIdx).toBeGreaterThan(0);
116+
expect(args[urlIdx + 1]).toBe('http://host.docker.internal:3001');
117+
});
118+
119+
it('token is passed via env var, not args', () => {
120+
const cfg = buildCoordinatorMCPConfig(baseOpts);
121+
const args = cfg.mcpServers['parallel-code'].args;
122+
expect(args).not.toContain('--token');
123+
expect(cfg.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe(baseOpts.token);
124+
});
125+
126+
it('args contain --coordinator-id', () => {
127+
const cfg = buildCoordinatorMCPConfig(baseOpts);
128+
const args = cfg.mcpServers['parallel-code'].args;
129+
const coordIdx = args.indexOf('--coordinator-id');
130+
expect(coordIdx).toBeGreaterThan(0);
131+
expect(args[coordIdx + 1]).toBe(baseOpts.coordinatorTaskId);
132+
});
133+
134+
it('omits --skip-permissions by default', () => {
135+
const cfg = buildCoordinatorMCPConfig(baseOpts);
136+
expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions');
137+
});
138+
139+
it('adds --skip-permissions when both flags are true', () => {
140+
const cfg = buildCoordinatorMCPConfig({
141+
...baseOpts,
142+
skipPermissions: true,
143+
propagateSkipPermissions: true,
144+
});
145+
expect(cfg.mcpServers['parallel-code'].args).toContain('--skip-permissions');
146+
});
147+
148+
it('does NOT add --skip-permissions when propagateSkipPermissions is false', () => {
149+
const cfg = buildCoordinatorMCPConfig({
150+
...baseOpts,
151+
skipPermissions: true,
152+
propagateSkipPermissions: false,
153+
});
154+
expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions');
155+
});
156+
157+
it('does NOT add --skip-permissions when skipPermissions is false', () => {
158+
const cfg = buildCoordinatorMCPConfig({
159+
...baseOpts,
160+
skipPermissions: false,
161+
propagateSkipPermissions: true,
162+
});
163+
expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions');
164+
});
165+
166+
it('JSON-serialised output is valid JSON with the parallel-code key', () => {
167+
const cfg = buildCoordinatorMCPConfig(baseOpts);
168+
const json = JSON.stringify(cfg, null, 2);
169+
const parsed = JSON.parse(json) as typeof cfg;
170+
expect(parsed.mcpServers['parallel-code']).toBeDefined();
171+
});
172+
});

electron/ipc/git.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
getUncommittedChangedFiles,
6161
checkMergeStatus,
6262
listImportableWorktrees,
63+
mergeTask,
6364
} from './git.js';
6465

6566
type ExecFileCallback = (err: Error | null, stdout: string, stderr: string) => void;
@@ -1499,3 +1500,82 @@ describe('checkMergeStatus (cherry-pick filtered ahead count)', () => {
14991500
expect(result.conflicting_files).toEqual(['src/foo.ts', 'src/bar.ts']);
15001501
});
15011502
});
1503+
1504+
// ---------------------------------------------------------------------------
1505+
// mergeTask — mergeWorktreePath skips checkout and routes ops to that path
1506+
// ---------------------------------------------------------------------------
1507+
1508+
describe('mergeTask (mergeWorktreePath)', () => {
1509+
beforeEach(() => {
1510+
vi.clearAllMocks();
1511+
});
1512+
1513+
it('skips git checkout and routes status/merge ops through mergeWorktreePath', async () => {
1514+
type CallRecord = { args: string[]; cwd: string | undefined };
1515+
const callRecords: CallRecord[] = [];
1516+
1517+
vi.mocked(execFile).mockImplementation(((
1518+
_cmd: string,
1519+
args: string[],
1520+
opts: unknown,
1521+
cb: ExecFileCallback,
1522+
) => {
1523+
callRecords.push({ args, cwd: (opts as { cwd?: string } | null)?.cwd });
1524+
1525+
const [cmd] = args;
1526+
// Repo lock key
1527+
if (cmd === 'rev-parse' && args.includes('--git-common-dir')) return cb(null, '.git\n', '');
1528+
// localBranchExists('main') → true; all other --verify refs → false
1529+
if (cmd === 'rev-parse' && args[1] === '--verify' && args[2] === 'refs/heads/main')
1530+
return cb(null, 'sha\n', '');
1531+
if (cmd === 'rev-parse' && args[1] === '--verify') return cb(new Error('no ref'), '', '');
1532+
// No remote HEAD
1533+
if (cmd === 'symbolic-ref') return cb(new Error('no remote'), '', '');
1534+
// merge-base for diff-base detection
1535+
if (cmd === 'merge-base') return cb(null, 'mergebase000\n', '');
1536+
// cherry-pick unique-commit list — empty means fully merged (→ no rev-list call)
1537+
if (cmd === 'log' && args.includes('--cherry-pick')) return cb(null, '', '');
1538+
// diff stats
1539+
if (cmd === 'diff') return cb(null, '', '');
1540+
// status --porcelain — clean working tree
1541+
if (cmd === 'status') return cb(null, '', '');
1542+
// merge
1543+
if (cmd === 'merge') return cb(null, '', '');
1544+
return cb(null, '', '');
1545+
}) as unknown as typeof execFile);
1546+
1547+
const projectRoot = uniqueRepoPath();
1548+
const mergeWorktreePath = uniqueRepoPath();
1549+
const branchName = 'feature/test-branch';
1550+
1551+
await mergeTask(
1552+
projectRoot,
1553+
branchName,
1554+
false, // squash
1555+
null, // message
1556+
false, // cleanup
1557+
'main', // baseBranch
1558+
undefined,
1559+
mergeWorktreePath,
1560+
);
1561+
1562+
// git checkout must never be called when mergeWorktreePath is supplied
1563+
expect(callRecords.some((r) => r.args[0] === 'checkout')).toBe(false);
1564+
1565+
// git merge must be called, and every such call must use mergeWorktreePath as cwd
1566+
const mergeCalls = callRecords.filter((r) => r.args[0] === 'merge');
1567+
expect(mergeCalls.length).toBeGreaterThan(0);
1568+
for (const r of mergeCalls) {
1569+
expect(r.cwd).toBe(mergeWorktreePath);
1570+
}
1571+
1572+
// git status --porcelain must be called with mergeWorktreePath as cwd
1573+
const statusCalls = callRecords.filter(
1574+
(r) => r.args[0] === 'status' && r.args.includes('--porcelain'),
1575+
);
1576+
expect(statusCalls.length).toBeGreaterThan(0);
1577+
for (const r of statusCalls) {
1578+
expect(r.cwd).toBe(mergeWorktreePath);
1579+
}
1580+
});
1581+
});

0 commit comments

Comments
 (0)