Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
917f000
feat(shortcuts): add Cmd+1-9 to jump to task by sidebar position
brooksc May 2, 2026
c7c3865
feat: add coordinating agent via MCP server
Mar 19, 2026
81a728d
Fixes to MCP and task coordination UI
brooksc May 4, 2026
ae67a42
Add Codex argument regression tests
brooksc May 2, 2026
10e4d7f
Fix Codex arguments
brooksc May 2, 2026
2efac22
feat: default to plain rebase when no conflicts detected
brooksc May 2, 2026
5fb8270
share docker agent auth
brooksc May 2, 2026
7421576
fix(mcp): port fallback and REST input validation
brooksc May 4, 2026
b8ff20c
fix(mcp): update deleteTask call to use options object signature
brooksc May 4, 2026
101dc7d
feat(mcp): pilot/co-pilot control handoff for coordinated tasks
brooksc May 4, 2026
7e37820
feat(mcp): add coordinator notification IPC channel enums
brooksc May 6, 2026
55afb85
feat(mcp): add coordinator notification types
brooksc May 6, 2026
ec2a313
feat(mcp): coordinator notification queue and reviewable-state detection
brooksc May 6, 2026
628f5f6
test(mcp): orchestrator coordinator notification unit tests
brooksc May 6, 2026
0fbbeb6
feat(mcp): register coordinator notification IPC handlers
brooksc May 6, 2026
be522cd
feat(mcp): wire coordinator registration/deregistration and staged no…
brooksc May 6, 2026
fc26b54
feat(mcp): disable coordinator checkbox when an active coordinator ex…
brooksc May 6, 2026
80a8af8
feat(mcp): PromptInput staged notification auto-fire with prompt-deli…
brooksc May 6, 2026
944bcac
feat(mcp): pass stagedNotification and coordinatedBy through TaskPane…
brooksc May 6, 2026
f0e7ce5
fix(mcp): pass --mcp-config to coordinator agent spawn args
brooksc May 6, 2026
8f9fdff
fix(mcp): re-subscribe orchestrator output callback after renderer re…
brooksc May 6, 2026
d87d7da
feat(mcp): sub-task signal_done tool — explicit completion signal
brooksc May 6, 2026
d84e65b
feat(mcp): wait_for_signal_done tool, signal-done chip indicator, MCP…
brooksc May 6, 2026
a87c8c6
docs: coordinator notification system test plan
brooksc May 6, 2026
0676fe9
refactor(mcp): rename orchestrator→coordinator, bug fixes, control ha…
brooksc May 7, 2026
ed0605c
chore: merge upstream/main — resolve NewTaskDialog structural conflict
brooksc May 7, 2026
92ef957
docs: add control handoff UX items to KNOWN-TODOS
brooksc May 7, 2026
a55d98e
docs: add missing TODO items from PR comment commitments
brooksc May 7, 2026
0be08ca
chore: trigger GitHub PR mergeability re-evaluation
brooksc May 7, 2026
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
104 changes: 104 additions & 0 deletions KNOWN-TODOS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Known TODOs

## Coordinator + Docker container support

**Status:** Not implemented. Docker mode and coordinator mode are mutually exclusive in the UI.

**Problem:** When a coordinator task runs in Docker, sub-agents created via `create_task` are spawned as native host processes (no Docker isolation). This defeats the security purpose of Docker mode.

**Considered approach — same container (`docker exec`):**

- Coordinator would need to run in "main" (direct git isolation, not a worktree) so its volume mount (`-v /repo:/repo`) covers the whole project root including all sub-task worktrees at `/repo/.worktrees/task/...`
- Sub-agents would be spawned via `docker exec parallel-code-<coordinatorAgentId> claude ...`
- Lightweight — no new container startup per sub-agent
- Requires: passing coordinator's Docker container name through coordinator; enforcing direct git isolation for coordinator mode

**Alternative — separate containers per sub-agent:**

- Each sub-agent gets its own `docker run` mounting its own worktree path
- Must also bind-mount the per-task MCP config file from `/tmp`
- More containers to manage but cleaner isolation and independent lifecycle
- Works regardless of coordinator git isolation mode

**Prerequisite decision:** Should coordinator mode force direct git isolation (running on main branch, no worktree)? This would simplify the same-container approach and is conceptually correct since coordinators shouldn't be committing code themselves.

---

## Configurable coordinator notification delay

**Status:** Hardcoded at 60 s (`COORDINATOR_NOTIFICATION_DELAY_MS` in `electron/mcp/coordinator.ts`).

**What's missing:** Expose this value in app settings so power users can tune the quiet-period before the staged notification auto-fires. The failure fast-path is always `max(10 s, delay / 4)`, so reducing the base delay also tightens the error path.

**Design note:** The delay is owned by the main process (coordinator.ts computes `autoFireAt`), so a settings change needs to flow through IPC at startup or be read fresh each time a notification is staged.

---

## Orphaned notification badge UI

**Status:** `MCP_CoordinatorOrphanedNotification` is received in the renderer and `console.warn`'d, but nothing surfaces visually.

**What's missing:** When a sub-task becomes reviewable but its coordinator is already closed, a sidebar badge (or title-bar indicator) should appear on the sub-task so the user knows to review it manually. Currently the completion is silently lost from the user's perspective.

**Suggested approach:** On receiving `MCP_CoordinatorOrphanedNotification`, set a `needsReview` flag on the sub-task in the store and show a badge (e.g. amber dot) in the task title bar alongside the branch name badge.

---

## Re-stage notification after user edits textarea

**Status:** When the user edits the staged text (`userEdited = true`) and manually sends their own message (not the staged notification), the orchestrator's queue stays pending. There is a periodic re-stage timer (`COORDINATOR_RESTAMP_DELAY_MS = 5 min`) but it only fires when new completions arrive — not as a standalone idle re-check.

**What's missing:** If no new sub-tasks complete, the pending notification queue is never re-staged after the user's edited send. The user would have no way to know there are still unacknowledged sub-task completions until another sub-task finishes.

**Fix:** Start a standalone timer after a `userEdited` manual send that re-stages the pending queue after the restamp delay, independently of new completions.

---

## Control handoff UX — naming and input gating

**Status:** Partially implemented. Banner and buttons exist but two issues remain.

**Naming:** "Take Control" / "Return to Orchestrator" overstate what happens. The coordinator agent is still running — only its ability to send prompts to that sub-task is blocked. More accurate labels: **"Pause coordinator"** / **"Resume coordinator"** (or just "Pause" / "Resume" with banner context).

**Input gating:** When the coordinator has control, the sub-task's PromptInput textarea and raw xterm terminal are not gated — the user can type freely, which could collide with a coordinator `send_prompt` firing simultaneously. The fix: dim/disable PromptInput and suppress xterm keyboard input when `controlledBy === 'coordinator'`, with "Pause coordinator" as the only way to re-enable input. This makes the model discoverable rather than relying on the user reading the banner before typing.

---

## Beta gating — `experimental.coordinatorMode` flag

**Status:** Not implemented. Coordinator mode is currently always available.

**What's needed (per repo owner feedback):**

- A single `experimental.coordinatorMode` setting, off by default
- When off: MCP server module is not started (lazy `import()` on flag flip), Coordinator option in NewTaskDialog is hidden, and coordinator-related IPC channels are not registered — zero footprint for users who don't opt in
- When on: current behavior

**Note:** The settings UI placement for this flag depends on the tabbed settings dialog (separate PR — see below).

---

## Tabbed settings dialog (separate PR)

**Status:** Not implemented. Settings is a flat scrollable list that's getting long.

**What's needed:** Split into tabs, e.g. General / Experimental (or Beta). The `experimental.coordinatorMode` flag and other power-user settings live in the Experimental tab. This should land as its own PR independent of coordinator work.

---

## Post-restart MCP path — integration test

**Status:** No integration test. The port/token rotation that rewrites the sub-task MCP config on coordinator restart is the most fragile part of the system by design.

**What's needed:** An integration test that confirms after a coordinator restart (new port, new token), the config file the sub-agent reads is correctly rewritten and the sub-agent can reconnect. Unit tests don't cover this path.

---

## `skipPermissions` guardrail

**Status:** Sub-tasks silently inherit `--dangerously-skip-permissions` from the coordinator if the coordinator was started with it. No per-task confirmation.

**What's needed:** Two changes:

1. An explicit UI confirmation when creating a coordinator task with skip-permissions, making clear that all sub-tasks will inherit it
2. A "propagate skip-permissions to sub-tasks" checkbox (default off) so users must explicitly opt in to inheritance rather than getting it automatically — important for the "40 tasks" workflow where per-task confirmation would be impractical
18 changes: 18 additions & 0 deletions electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,22 @@ 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',
// Coordinator notifications (main → renderer)
MCP_CoordinatorNotificationStaged = 'mcp_coordinator_notification_staged',
MCP_CoordinatorOrphanedNotification = 'mcp_coordinator_orphaned_notification',
// Coordinator lifecycle (renderer → main)
MCP_CoordinatorRegistered = 'mcp_coordinator_registered',
MCP_CoordinatorDeregistered = 'mcp_coordinator_deregistered',
MCP_CoordinatorNotificationAck = 'mcp_coordinator_notification_ack',
MCP_CoordinatedTaskPromptDelivered = 'mcp_coordinated_task_prompt_delivered',
}
215 changes: 214 additions & 1 deletion electron/ipc/register.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ipcMain, dialog, shell, app, clipboard, BrowserWindow, Notification } from 'electron';
import crypto from 'crypto';
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 @@ -29,7 +30,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 { Coordinator } from '../mcp/coordinator.js';
import {
getGitIgnoredDirs,
getMainBranch,
Expand Down Expand Up @@ -78,6 +80,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 @@ -227,6 +251,10 @@ export function registerAllHandlers(win: BrowserWindow): void {
let remoteServer: ReturnType<typeof startRemoteServer> | null = null;
const taskNames = new Map<string, string>();

// --- MCP coordinator ---
const coordinator = new Coordinator();
coordinator.setWindow(win);

// --- PTY commands ---
ipcMain.handle(IPC.SpawnAgent, (_e, args) => {
assertString(args.command, 'command');
Expand Down Expand Up @@ -928,6 +956,7 @@ export function registerAllHandlers(win: BrowserWindow): void {
lastLine: '',
};
},
coordinator,
});
return {
url: remoteServer.url,
Expand Down Expand Up @@ -958,6 +987,190 @@ export function registerAllHandlers(win: BrowserWindow): void {
};
});

// --- MCP server management ---
ipcMain.handle(
IPC.StartMCPServer,
async (
_e,
args: {
coordinatorTaskId: string;
projectId: string;
projectRoot: string;
worktreePath?: string;
skipPermissions?: boolean;
agentCommand?: string;
agentArgs?: string[];
},
) => {
// Set coordinator's default project + coordinator task ID
coordinator.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: '',
};
},
coordinator,
});
}

// 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}`;
coordinator.setMCPServerInfo(serverUrl, remoteServer.token, mcpServerPath);
coordinator.setCoordinatorSpawnDefaults(args.agentCommand ?? 'claude', args.agentArgs ?? []);

const mcpConfig = {
mcpServers: {
'parallel-code': {
type: 'stdio' as const,
command: 'node',
args: [
mcpServerPath,
'--url',
serverUrl,
'--token',
remoteServer.token,
'--coordinator-id',
args.coordinatorTaskId,
...(args.skipPermissions ? ['--skip-permissions'] : []),
],
},
},
};

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: 'coordinator' | 'human' }) => {
coordinator.setTaskControl(args.taskId, args.controlledBy);
},
);

ipcMain.handle(
IPC.MCP_CoordinatorRegistered,
(_e, args: { coordinatorTaskId: string; projectId: string }) => {
assertString(args.coordinatorTaskId, 'coordinatorTaskId');
assertString(args.projectId, 'projectId');
coordinator.registerCoordinator(args.coordinatorTaskId, args.projectId);
},
);

ipcMain.handle(IPC.MCP_CoordinatorDeregistered, (_e, args: { coordinatorTaskId: string }) => {
assertString(args.coordinatorTaskId, 'coordinatorTaskId');
coordinator.deregisterCoordinator(args.coordinatorTaskId);
});

ipcMain.handle(IPC.MCP_CoordinatedTaskPromptDelivered, (_e, args: { taskId: string }) => {
assertString(args.taskId, 'taskId');
coordinator.markPromptDelivered(args.taskId);
});

ipcMain.handle(
IPC.MCP_CoordinatorNotificationAck,
(_e, args: { coordinatorTaskId: string; batchId: string }) => {
assertString(args.coordinatorTaskId, 'coordinatorTaskId');
assertString(args.batchId, 'batchId');
coordinator.ackNotification(args.coordinatorTaskId, args.batchId);
},
);

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
Loading