Skip to content
Closed
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: 2 additions & 0 deletions .semgrep/filesystem-safety.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ rules:
include:
- '**/electron/mcp/**'
- '**/electron/ipc/register.ts'
- '**/electron/ipc/mcp-runtime.ts'
exclude:
- '**/electron/mcp/atomic.ts'
- '**/*.test.ts'
Expand All @@ -27,3 +28,4 @@ rules:
paths:
include:
- '**/electron/ipc/register.ts'
- '**/electron/ipc/mcp-runtime.ts'
66 changes: 48 additions & 18 deletions docs/runtime-separation-plan.md

Large diffs are not rendered by default.

7 changes: 2 additions & 5 deletions electron/ipc/docker-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,8 @@
*/

import { describe, expect, it } from 'vitest';
import {
buildCoordinatorMCPConfig,
getDockerMcpServerDestPath,
selectMcpJsonDir,
} from './register.js';
import { buildCoordinatorMCPConfig, selectMcpJsonDir } from './register.js';
import { getDockerMcpServerDestPath } from './mcp-paths.js';
import { getMCPRemoteServerUrl } from '../mcp/config.js';

// ── MCP server URL ─────────────────────────────────────────────────────────────
Expand Down
19 changes: 10 additions & 9 deletions electron/ipc/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { execFile, execFileSync as _execFileSync, spawn } from 'child_process';
import { promisify } from 'util';
import fs from 'fs';
import path from 'path';
import type { BrowserWindow } from 'electron';
import type { Notify } from './notify.js';
import { debug as logDebug } from '../log.js';
import {
appendGitInfoExcludeBlock,
Expand Down Expand Up @@ -905,14 +905,19 @@ async function removeDirWithRetries(dirPath: string): Promise<unknown> {

// --- Public functions (used by tasks.ts and register.ts) ---

/** Where `createWorktree` puts the worktree for a branch. */
export function worktreePathFor(repoRoot: string, branchName: string): string {
return `${repoRoot}/.worktrees/${branchName}`;
}

export async function createWorktree(
repoRoot: string,
branchName: string,
symlinkDirs: string[],
baseBranch?: string,
forceClean = false,
): Promise<{ path: string; branch: string }> {
const worktreePath = `${repoRoot}/.worktrees/${branchName}`;
const worktreePath = worktreePathFor(repoRoot, branchName);

if (forceClean) {
// Clean up stale worktree/branch from a previous session that wasn't properly removed
Expand Down Expand Up @@ -1264,7 +1269,7 @@ export async function removeWorktree(
// After the user adopts a branch the agent switched the worktree to, the
// folder keeps its original branch-derived name — callers that know the real
// path must pass it, deriving from branchName is only a fallback.
const worktreePath = explicitWorktreePath ?? `${repoRoot}/.worktrees/${branchName}`;
const worktreePath = explicitWorktreePath ?? worktreePathFor(repoRoot, branchName);

if (!fs.existsSync(repoRoot)) return;

Expand Down Expand Up @@ -2169,7 +2174,7 @@ export async function getFileDiffFromBranch(
}

export function pushTask(
win: BrowserWindow,
notify: Notify,
projectRoot: string,
branchName: string,
channelId: string,
Expand All @@ -2180,11 +2185,7 @@ export function pushTask(
stdio: ['ignore', 'pipe', 'pipe'],
});

const send = (msg: string) => {
if (!win.isDestroyed()) {
win.webContents.send(`channel:${channelId}`, msg);
}
};
const send = (msg: string) => notify(`channel:${channelId}`, msg);

proc.stdout?.on('data', (chunk: Buffer) => {
send(chunk.toString('utf8'));
Expand Down
21 changes: 21 additions & 0 deletions electron/ipc/mcp-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import path from 'path';
import { fileURLToPath } from 'url';

/**
* The bundled `mcp-server.cjs` on the host. Packaged builds run it from
* `app.asar.unpacked`, because a child process cannot execute from inside the archive.
* Keep this file in `electron/ipc/`: the path is relative to its build output.
*/
export function hostMcpServerPath(): string {
return path
.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'mcp-server.cjs')
.replace('/app.asar/', '/app.asar.unpacked/');
}

/** Path where `mcp-server.cjs` is copied inside the Docker-mounted worktree. */
export function getDockerMcpServerDestPath(
worktreePath: string | undefined,
projectRoot: string,
): string {
return path.join(worktreePath ?? projectRoot, '.parallel-code', 'mcp-server.cjs');
}
213 changes: 213 additions & 0 deletions electron/ipc/mcp-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { IPC } from './channels.js';
import type { Notify } from './notify.js';
import { onPtyEvent } from './pty.js';
import { appendGitInfoExcludeBlock } from './git-exclude.js';
import { loadAppState, saveAppState } from './persistence.js';
import { getDockerMcpServerDestPath, hostMcpServerPath } from './mcp-paths.js';
import {
createRemoteTransport,
type RemoteServerOptions,
type RemoteTransport,
} from './remote-transport.js';
import { DelegationService } from '../mcp/delegation.js';
import type { Coordinator } from '../mcp/coordinator.js';
import { removeCanvasConfig } from '../mcp/canvas-config.js';
import { getMCPRemoteServerUrl } from '../mcp/config.js';
import type { TaskAuthorityInput } from '../shared/delegation-types.js';
import { warn as logWarn, errMessage } from '../log.js';

export interface McpRuntimeOptions {
notify: Notify;
defaultPort: number;
serverOptions: () => RemoteServerOptions;
rememberedDevicesPath: () => string;
/** Runs once, when the coordinator first loads; its IPC handlers are registered here. */
onCoordinatorLoaded: () => void;
}

export interface McpRuntime {
transport: RemoteTransport;
delegation: DelegationService;
/** A kill must also cancel startup while optional MCP transport is awaiting I/O. */
pendingSpawns: Map<string, { sessionInstanceId: string }>;
/** Which spawn minted the agent's live canvas token. `pendingSpawns` cannot answer this: a
* kill clears the entry to cancel the spawn, and a finished restart clears its own, so an
* absent entry means both "nobody owns this" and "the owner already left". */
canvasOwners: Map<string, { sessionInstanceId: string }>;
/** Container agents on macOS reach the host only through a wide bind; track who needs it. */
wideBindAgents: Set<string>;
needsWideBind: (dockerMode: boolean) => boolean;
/** The coordinator, or null until `ensureCoordinator` has loaded it. It is never unloaded. */
coordinator(): Coordinator | null;
/** Lazily loads the shared coordination backend. Safe to call multiple times. */
ensureCoordinator(): Promise<void>;
}

function restoreOrchestrationEnabled(): boolean {
try {
const saved = loadAppState();
return saved ? JSON.parse(saved).mcpOrchestrationEnabled !== false : true;
} catch (error) {
logWarn('mcp', 'Could not restore orchestration setting; keeping orchestration disabled', {
err: errMessage(error),
});
return false;
}
}

/**
* The MCP side of the main process: the remote transport, the lazily loaded
* coordinator, delegation, and which agents hold canvas tokens or a wide bind.
*/
export function createMcpRuntime(opts: McpRuntimeOptions): McpRuntime {
const { notify } = opts;
let coordinator: Coordinator | null = null;
const pendingSpawns = new Map<string, { sessionInstanceId: string }>();
const canvasOwners = new Map<string, { sessionInstanceId: string }>();
const wideBindAgents = new Set<string>();
const needsWideBind = (dockerMode: boolean) => dockerMode && process.platform !== 'linux';

const transport = createRemoteTransport({
defaultPort: opts.defaultPort,
serverOptions: opts.serverOptions,
coordinator: () => coordinator,
needsWideBind,
wideBindInUse: () =>
wideBindAgents.size > 0 ||
(process.platform !== 'linux' && delegation.requiresWideTransport()),
rememberedDevicesPath: opts.rememberedDevicesPath,
});

const delegation: DelegationService = new DelegationService({
orchestrationEnabled: restoreOrchestrationEnabled(),
coordinator: async () => {
await ensureCoordinator();
if (!coordinator) throw new Error('Delegation unavailable.');
return coordinator;
},
currentCoordinator: () => coordinator,
prepareParent: prepareDelegationParent,
sessions: () => transport.current()?.getSessionAgents() ?? [],
changed: (event) => notify(IPC.DelegationChanged, event),
persist: () => {
const json = loadAppState();
if (json) saveAppState(delegation.normalizeState(json));
},
parentCreated: (taskId) => notify(IPC.MCP_TaskStateSync, { taskId, delegationParent: true }),
});

async function prepareDelegationParent(task: TaskAuthorityInput): Promise<void> {
await ensureCoordinator();
if (!coordinator) throw new Error('Delegation unavailable.');
const server = await transport.ensureForMcp(task.dockerMode === true);
const hostServerPath = hostMcpServerPath();
const serverPath = task.dockerMode
? getDockerMcpServerDestPath(task.worktreePath, task.projectRoot)
: hostServerPath;
if (task.dockerMode) {
fs.mkdirSync(path.dirname(serverPath), { recursive: true });
fs.copyFileSync(hostServerPath, serverPath);
appendGitInfoExcludeBlock(
task.worktreePath,
'.parallel-code/',
'# Parallel Code MCP runtime\n.parallel-code/\n',
(error) =>
logWarn('mcp', 'Could not exclude MCP runtime directory', { err: errMessage(error) }),
);
}
coordinator.registerCoordinator(task.taskId, task.projectId, {
projectRoot: task.projectRoot,
worktreePath: task.worktreePath,
branchName: task.branchName,
spawnDefaults: { command: task.agentCommand, args: task.agentArgs },
agentEnvFile: task.agentEnvFile,
automaticNotifications: task.autoSendChildUpdates ?? task.coordinatorMode ?? false,
paused: task.delegationPaused,
skipPermissions: task.propagateSkipPermissions,
maxConcurrentTasks: task.maxConcurrentTasks,
verifyCommand: task.verifyCommand,
});
coordinator.setDockerContainerName(task.taskId, task.dockerMode ? 'delegation' : null);
coordinator.setDockerImage(task.taskId, task.dockerImage ?? null);
coordinator.setMCPServerInfo(
task.taskId,
getMCPRemoteServerUrl(server.port, task.dockerMode ? 'delegation' : undefined),
server.coordinatorTokenFor(task.taskId),
server.subtaskToken,
serverPath,
);
}

// The canvas token is revoked on exit; the config file holding it must go too.
onPtyEvent('exit', (agentId) => {
try {
removeCanvasConfig(agentId);
} catch (error) {
console.warn('Could not remove canvas MCP credentials:', error);
}
wideBindAgents.delete(agentId);
canvasOwners.delete(agentId);
// The server's own exit listener runs after this one; drop the agent here so the
// idle check below already sees it gone.
transport.current()?.unregisterCanvasAgent(agentId);
delegation.expireMessages();
transport
.stopIfIdle()
.catch((error) => console.warn('[MCP] Could not stop the idle transport:', error));
});

let coordinatorStarting: Promise<void> | undefined;
async function ensureCoordinator(): Promise<void> {
if (coordinator) return;
if (coordinatorStarting) return coordinatorStarting;
coordinatorStarting = (async () => {
const { Coordinator } = await import('../mcp/coordinator.js');
const loaded = new Coordinator();
coordinator = loaded;
loaded.setOrchestrationEnabled(delegation.isOrchestrationEnabled());
loaded.setNotify(notify);
loaded.setSessionMcpProvider((task) => {
const server = transport.current();
if (!delegation.getTask(task.coordinatorTaskId) || !server) return undefined;
if (!delegation.getTask(task.id)) {
if (task.status !== 'creating')
throw new Error('Task authority was not validated before restoring its MCP session.');
delegation.registerChild(task);
}
const sessionCapabilities = delegation.capabilities(task.id);
if (!sessionCapabilities) throw new Error('Task authority is unavailable.');
let owner = canvasOwners.get(task.agentId);
if (!owner) {
owner = { sessionInstanceId: crypto.randomUUID() };
canvasOwners.set(task.agentId, owner);
}
if (needsWideBind(!!task.dockerContainerName)) wideBindAgents.add(task.agentId);
const token = server.registerCanvasAgent(task.id, task.agentId, undefined, {
sessionInstanceId: owner.sessionInstanceId,
capabilities: sessionCapabilities,
});
return { token, sessionCapabilities };
});
opts.onCoordinatorLoaded();
})();
try {
await coordinatorStarting;
} finally {
coordinatorStarting = undefined;
}
}

return {
transport,
delegation,
pendingSpawns,
canvasOwners,
wideBindAgents,
needsWideBind,
coordinator: () => coordinator,
ensureCoordinator,
};
}
8 changes: 8 additions & 0 deletions electron/ipc/notify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Sends a message to the desktop UI.
*
* Runtime modules (PTY, git, plan and step watchers, the coordinator) take this
* instead of a `BrowserWindow`, so they do not depend on Electron and can run
* without a window. The desktop shell supplies one with `windowNotifier`.
*/
export type Notify = (channel: string, payload: unknown) => void;
4 changes: 1 addition & 3 deletions electron/ipc/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import type { BrowserWindow } from 'electron';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IPC } from './channels.js';
import { readPlanForWorktree, startPlanWatcher, stopAllPlanWatchers } from './plans.js';
Expand Down Expand Up @@ -45,8 +44,7 @@ function writeFile(relativePath: string, content = '# Theme plan'): void {

function watchPlans() {
const send = vi.fn();
const win = { isDestroyed: () => false, webContents: { send } } as unknown as BrowserWindow;
startPlanWatcher(win, 'task-1', worktreePath);
startPlanWatcher(send, 'task-1', worktreePath);
return send;
}

Expand Down
Loading
Loading