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
128 changes: 128 additions & 0 deletions COORDINATING-AGENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Coordinating Agent — Implementation Notes

## Overview

A Claude Code agent running inside a task can now programmatically create and coordinate other tasks in Parallel Code via MCP tools. The coordinating agent can spawn sub-tasks, send prompts, monitor completion, read diffs, and merge results — all without manual human orchestration.

## Architecture

```
Claude Code (coordinating task PTY)
| MCP stdio (JSON-RPC)
v
MCP Server Process (electron/mcp/server.ts)
| HTTP + Bearer token
v
Electron Remote Server (electron/remote/server.ts)
| Direct function calls
v
Orchestrator (electron/mcp/orchestrator.ts)
| Uses existing backend primitives
v
PTY / Git / Worktree functions
```

## MCP Tools Available to Coordinating Agents

| Tool | Description |
| ----------------- | --------------------------------------- |
| `create_task` | Create a new task with worktree + agent |
| `list_tasks` | List all orchestrated tasks with status |
| `get_task_status` | Get task status + git summary |
| `send_prompt` | Send prompt to a task's agent |
| `wait_for_idle` | Wait until agent becomes idle |
| `get_task_diff` | Get changed files + unified diff |
| `get_task_output` | Get recent terminal output |
| `merge_task` | Merge task branch to main |
| `close_task` | Close and clean up a task |

## Files Created

- `electron/mcp/prompt-detect.ts` — Shared prompt detection (extracted from taskStatus.ts)
- `electron/mcp/types.ts` — Shared types for orchestrator, API, MCP tools
- `electron/mcp/orchestrator.ts` — Main-process task lifecycle management
- `electron/mcp/client.ts` — HTTP client for MCP server -> remote server
- `electron/mcp/server.ts` — Standalone MCP server (stdio transport)
- `src/components/SubTaskStrip.tsx` — Sub-task status chips on coordinator panel

## Files Modified

- `electron/ipc/channels.ts` — MCP IPC channels
- `electron/remote/server.ts` — Orchestrator REST API endpoints
- `electron/ipc/register.ts` — Orchestrator init, MCP IPC handlers
- `src/store/types.ts` — `coordinatorMode`, `coordinatedBy`, `mcpConfigPath` on Task
- `src/store/tasks.ts` — Coordinator task creation, MCP event listeners
- `src/store/taskStatus.ts` — Imports from shared prompt-detect module
- `src/store/persistence.ts` — Persist/restore coordinator fields
- `src/store/store.ts` — Export `initMCPListeners`
- `src/App.tsx` — Initialize MCP listeners
- `src/components/NewTaskDialog.tsx` — "Coordinator mode" checkbox
- `src/components/TaskPanel.tsx` — SubTaskStrip, `--mcp-config` in agent args
- `src/components/Sidebar.tsx` — "via Coordinator" labels on sub-tasks
- `package.json` — Added `@modelcontextprotocol/sdk`, dev build identity

## UI Changes

1. **New Task Dialog** — "Coordinator mode" checkbox after agent selector. When enabled, shows a warning banner and auto-starts the MCP infrastructure on task creation.

2. **Sidebar** — Sub-tasks created by a coordinator show a "via {name}" label below the task name. Clicking the label navigates to the coordinator task.

3. **Task Panel** — Coordinator tasks show a horizontal sub-task status strip between the branch info bar and the notes panel. Each chip shows a StatusDot + task name and is clickable.

---

## Testing

### Build

The build identity has been changed to `com.parallel-code.app.dev` / "Parallel Code Dev" so it installs separately from the production app.

```bash
# From the worktree directory
npm run build
# .dmg is in release/
open release/*.dmg
```

### Test 1: MCP Server Standalone

1. Start the dev app
2. Open Settings, start the remote server (or it auto-starts with coordinator mode)
3. Run the MCP server directly to verify it connects:
```bash
node dist-electron/mcp/server.js --url http://127.0.0.1:7777 --token <token>
```
(The token is printed in the app's remote server settings)
4. Use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) or send JSON-RPC over stdin to call tools like `list_tasks`

### Test 2: Coordinator Task End-to-End

1. Link a project in the app
2. Click "New Task"
3. Check the **"Coordinator mode"** checkbox
4. Enter a prompt like: `Create two tasks: one to add a README.md and one to add a LICENSE file. Wait for both to complete, then merge them.`
5. Submit — the app will:
- Auto-start the remote server
- Write a temp MCP config file
- Spawn Claude Code with `--mcp-config <path>`
6. Verify:
- Sub-tasks appear in the sidebar with "via {coordinator-name}" labels
- The coordinator's panel shows a sub-task status strip with chips
- Clicking a chip navigates to that sub-task
- The coordinating agent can merge and close sub-tasks

### Test 3: UI Elements

1. Create a coordinator task (as above)
2. Wait for it to create at least one sub-task
3. Check sidebar: sub-task should show "via {name}" in muted text
4. Check coordinator panel: sub-task strip should appear with status dots
5. Click the "via" label — should navigate to coordinator
6. Click a chip in the strip — should navigate to that sub-task

### Test 4: Edge Cases

- Create multiple sub-tasks rapidly
- Close a sub-task while its agent is busy
- Close the coordinator while sub-tasks are still running
- Collapse and uncollapse a coordinator task
10 changes: 10 additions & 0 deletions electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,14 @@ export enum IPC {

// Logging
LogFromRenderer = 'log_from_renderer',

// MCP / Coordinating agent
StartMCPServer = 'start_mcp_server',
StopMCPServer = 'stop_mcp_server',
GetMCPStatus = 'get_mcp_status',
GetMCPLogs = 'get_mcp_logs',
MCP_TaskCreated = 'mcp_task_created',
MCP_TaskClosed = 'mcp_task_closed',
MCP_TaskStateSync = 'mcp_task_state_sync',
MCP_ControlChanged = 'mcp_control_changed',
}
173 changes: 172 additions & 1 deletion electron/ipc/register.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ipcMain, dialog, shell, app, clipboard, BrowserWindow, Notification } from 'electron';
import fs from 'fs';
import net from 'net';
import os from 'os';
import { fileURLToPath } from 'url';
import { IPC } from './channels.js';
Expand Down Expand Up @@ -28,7 +29,8 @@ import {
import { startStepsWatcher, stopStepsWatcher, readStepsForWorktree } from './steps.js';
import { initPrChecks, startPrChecksWatcher, stopPrChecksWatcher, isPrUrl } from './pr-checks.js';
import { readCoverageSummary } from './coverage.js';
import { startRemoteServer } from '../remote/server.js';
import { startRemoteServer, getMCPLogs } from '../remote/server.js';
import { Orchestrator } from '../mcp/orchestrator.js';
import {
getGitIgnoredDirs,
getMainBranch,
Expand Down Expand Up @@ -76,6 +78,28 @@ import {
} from './validate.js';
import { warn as logWarn } from '../log.js';

function findFreePort(start: number, end: number): Promise<number> {
return new Promise((resolve, reject) => {
let port = start;
const tryNext = () => {
if (port > end) {
reject(new Error(`No free port found in range ${start}–${end}`));
return;
}
const s = net.createServer();
s.listen(port, '127.0.0.1', () => {
const found = port;
s.close(() => resolve(found));
});
s.on('error', () => {
port++;
tryNext();
});
};
tryNext();
});
}

function errMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
Expand Down Expand Up @@ -166,6 +190,10 @@ export function registerAllHandlers(win: BrowserWindow): void {
let remoteServer: ReturnType<typeof startRemoteServer> | null = null;
const taskNames = new Map<string, string>();

// --- MCP orchestrator ---
const orchestrator = new Orchestrator();
orchestrator.setWindow(win);

// --- PTY commands ---
ipcMain.handle(IPC.SpawnAgent, (_e, args) => {
assertString(args.command, 'command');
Expand Down Expand Up @@ -820,6 +848,7 @@ export function registerAllHandlers(win: BrowserWindow): void {
lastLine: '',
};
},
orchestrator,
});
return {
url: remoteServer.url,
Expand Down Expand Up @@ -850,6 +879,148 @@ export function registerAllHandlers(win: BrowserWindow): void {
};
});

// --- MCP server management ---
ipcMain.handle(
IPC.StartMCPServer,
async (
_e,
args: {
coordinatorTaskId: string;
projectId: string;
projectRoot: string;
worktreePath?: string;
},
) => {
// Set orchestrator's default project + coordinator task ID
orchestrator.setDefaultProject(args.projectId, args.projectRoot, args.coordinatorTaskId);

// Start remote server if not running
if (!remoteServer) {
const thisDir = path.dirname(fileURLToPath(import.meta.url));
const distRemote = path.join(thisDir, '..', '..', 'dist-remote');
const port = await findFreePort(7777, 7800);
remoteServer = startRemoteServer({
port,
staticDir: distRemote,
getTaskName: (taskId: string) => taskNames.get(taskId) ?? taskId,
getAgentStatus: (agentId: string) => {
const meta = getAgentMeta(agentId);
return {
status: meta ? ('running' as const) : ('exited' as const),
exitCode: null,
lastLine: '',
};
},
orchestrator,
});
}

// Write temp MCP config file — use the bundled single-file MCP server
// (built by esbuild, no external deps needed at runtime)
const thisDir = path.dirname(fileURLToPath(import.meta.url));
let mcpServerPath = path.join(thisDir, '..', 'mcp-server.cjs');

// In packaged builds, asar-unpacked files live in app.asar.unpacked/
if (mcpServerPath.includes('/app.asar/')) {
mcpServerPath = mcpServerPath.replace('/app.asar/', '/app.asar.unpacked/');
}
const serverUrl = `http://127.0.0.1:${remoteServer.port}`;

const mcpConfig = {
mcpServers: {
'parallel-code': {
type: 'stdio' as const,
command: 'node',
args: [mcpServerPath, '--url', serverUrl, '--token', remoteServer.token],
},
},
};

const configJson = JSON.stringify(mcpConfig, null, 2);

// Write temp config for --mcp-config flag
const configPath = path.join(
app.getPath('temp'),
`parallel-code-mcp-${args.coordinatorTaskId}.json`,
);
fs.writeFileSync(configPath, configJson);

// Also write .mcp.json into the worktree so Claude Code auto-discovers it.
// Immediately git-exclude it so the token never gets committed.
if (args.worktreePath) {
const worktreeMcpPath = path.join(args.worktreePath, '.mcp.json');
fs.writeFileSync(worktreeMcpPath, configJson);

// Append to .git/info/exclude (local-only gitignore, not committed)
try {
const gitDir = path.join(args.worktreePath, '.git');
// Worktrees use a .git file pointing to the real gitdir
let infoDir: string;
if (fs.statSync(gitDir).isFile()) {
const gitFileContent = fs.readFileSync(gitDir, 'utf-8').trim();
const realGitDir = gitFileContent.replace(/^gitdir:\s*/, '');
infoDir = path.join(
path.isAbsolute(realGitDir)
? realGitDir
: path.resolve(args.worktreePath, realGitDir),
'info',
);
} else {
infoDir = path.join(gitDir, 'info');
}
fs.mkdirSync(infoDir, { recursive: true });
const excludePath = path.join(infoDir, 'exclude');
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, 'utf-8') : '';
if (!existing.includes('.mcp.json')) {
fs.appendFileSync(
excludePath,
'\n# Parallel Code MCP config (contains ephemeral token)\n.mcp.json\n',
);
}
} catch (err) {
console.warn('[MCP] Could not git-exclude .mcp.json:', err);
}

console.warn('[MCP] Worktree .mcp.json written to:', worktreeMcpPath);
}

console.warn('[MCP] Config written to:', configPath);
console.warn('[MCP] Server path:', mcpServerPath);
console.warn('[MCP] Remote URL:', serverUrl);

return {
configPath,
serverUrl,
token: remoteServer.token,
port: remoteServer.port,
};
},
);

ipcMain.handle(
IPC.MCP_ControlChanged,
(_e, args: { taskId: string; controlledBy: 'orchestrator' | 'human' }) => {
orchestrator.setTaskControl(args.taskId, args.controlledBy);
},
);

ipcMain.handle(IPC.StopMCPServer, async () => {
// The MCP server process is spawned by Claude Code (via --mcp-config),
// not by us. This handler is a no-op but kept for API completeness.
});

ipcMain.handle(IPC.GetMCPStatus, () => {
// The MCP server process is spawned by Claude Code (via --mcp-config),
// not by us. We report whether the remote HTTP server that the MCP
// server connects to is running — if it's up, MCP tools should work.
return {
mcpRunning: remoteServer !== null,
remoteRunning: remoteServer !== null,
};
});

ipcMain.handle(IPC.GetMCPLogs, () => getMCPLogs());

// --- Forward window events to renderer ---
win.on('focus', () => {
if (!win.isDestroyed()) win.webContents.send(IPC.WindowFocus);
Expand Down
Loading