Skip to content

Commit 9b24262

Browse files
brookscclaude
andcommitted
feat(mcp): pilot/co-pilot control handoff for coordinated tasks
Adds explicit control handoff between the orchestrating agent and the human user, modelled on a pilot/co-pilot system where control is always unambiguously held by one party. Each sub-task created by a coordinator has a new runtime field `controlledBy: 'orchestrator' | 'human'`. The task panel shows a persistent banner so the user always knows who has the stick: - Orchestrator driving: subtle bar + "Take Control" button - Human in control: amber warning banner "You have control — orchestrator is paused" + "Return to Orchestrator" button When the human holds control, the orchestrator's MCP tools are blocked: - `send_prompt` throws immediately so the coordinator agent knows it cannot proceed and must wait or work on other tasks - `wait_for_idle` resolves immediately so the coordinator is not left hanging; the status signals human control Control returns to the orchestrator only via explicit user action — never automatically. Returning control also fires any queued `waitForIdle` resolvers so the coordinator can resume. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 95f819e commit 9b24262

8 files changed

Lines changed: 1612 additions & 124 deletions

File tree

electron/ipc/channels.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,5 @@ export enum IPC {
141141
MCP_TaskCreated = 'mcp_task_created',
142142
MCP_TaskClosed = 'mcp_task_closed',
143143
MCP_TaskStateSync = 'mcp_task_state_sync',
144+
MCP_ControlChanged = 'mcp_control_changed',
144145
}

electron/ipc/register.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,6 +997,13 @@ export function registerAllHandlers(win: BrowserWindow): void {
997997
},
998998
);
999999

1000+
ipcMain.handle(
1001+
IPC.MCP_ControlChanged,
1002+
(_e, args: { taskId: string; controlledBy: 'orchestrator' | 'human' }) => {
1003+
orchestrator.setTaskControl(args.taskId, args.controlledBy);
1004+
},
1005+
);
1006+
10001007
ipcMain.handle(IPC.StopMCPServer, async () => {
10011008
// The MCP server process is spawned by Claude Code (via --mcp-config),
10021009
// not by us. This handler is a no-op but kept for API completeness.

electron/mcp/orchestrator.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export class Orchestrator {
2828
private idleResolvers = new Map<string, Array<() => void>>();
2929
private subscribers = new Map<string, (encoded: string) => void>();
3030
private decoders = new Map<string, TextDecoder>();
31+
private controlMap = new Map<string, 'orchestrator' | 'human'>();
3132
private win: BrowserWindow | null = null;
3233
private projectRoot: string | null = null;
3334
private projectId: string | null = null;
@@ -54,6 +55,18 @@ export class Orchestrator {
5455
});
5556
}
5657

58+
setTaskControl(taskId: string, who: 'orchestrator' | 'human'): void {
59+
this.controlMap.set(taskId, who);
60+
// If returning to orchestrator and there are idle resolvers queued, fire them
61+
if (who === 'orchestrator') {
62+
const resolvers = this.idleResolvers.get(taskId);
63+
if (resolvers?.length) {
64+
for (const resolve of resolvers) resolve();
65+
this.idleResolvers.delete(taskId);
66+
}
67+
}
68+
}
69+
5770
setWindow(win: BrowserWindow): void {
5871
this.win = win;
5972
}
@@ -223,6 +236,11 @@ export class Orchestrator {
223236
async sendPrompt(taskId: string, prompt: string): Promise<void> {
224237
const task = this.tasks.get(taskId);
225238
if (!task) throw new Error(`Task not found: ${taskId}`);
239+
if (this.controlMap.get(taskId) === 'human') {
240+
throw new Error(
241+
'Task is under human control. Return control to orchestrator before sending prompts.',
242+
);
243+
}
226244

227245
// Send text then Enter separately (like the frontend does)
228246
writeToAgent(task.agentId, prompt);
@@ -239,6 +257,9 @@ export class Orchestrator {
239257
private waitForIdleInternal(taskId: string, timeoutMs: number): Promise<void> {
240258
const task = this.tasks.get(taskId);
241259
if (!task) return Promise.reject(new Error(`Task not found: ${taskId}`));
260+
if (this.controlMap.get(taskId) === 'human') {
261+
return Promise.resolve(); // resolve immediately — caller gets control-change event instead
262+
}
242263
if (task.status === 'idle' || task.status === 'exited') return Promise.resolve();
243264

244265
return new Promise((resolve, reject) => {

0 commit comments

Comments
 (0)