Skip to content
Open
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
1 change: 1 addition & 0 deletions electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export enum IPC {
MCP_TaskCreated = 'mcp_task_created',
MCP_TaskClosed = 'mcp_task_closed',
MCP_TaskStateSync = 'mcp_task_state_sync',
MCP_TaskLandingReviewCleared = 'mcp_task_landing_review_cleared',
MCP_ControlChanged = 'mcp_control_changed',
// Coordinator notifications (main → renderer)
MCP_CoordinatorNotificationStaged = 'mcp_coordinator_notification_staged',
Expand Down
24 changes: 24 additions & 0 deletions electron/ipc/pty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,30 @@ describe('spawnAgent session reattach', () => {
).not.toThrow();
expect(mockPtySpawn).toHaveBeenCalledTimes(1);
});

it('replaces an existing same-id PTY when attachExisting is explicitly false', () => {
const win = createMockWindow();
const agentId = 'agent-replace';
const args = buildSpawnArgs({
agentId,
command: 'claude',
args: [],
dockerMode: false,
onOutput: { __CHANNEL_ID__: 'channel-1' },
});

spawnAgent(win, args);
const oldProc = mockPtySpawn.mock.results[0].value as ReturnType<typeof mockPtySpawn>;

spawnAgent(win, {
...args,
attachExisting: false,
onOutput: { __CHANNEL_ID__: 'channel-2' },
});

expect(oldProc.kill).toHaveBeenCalled();
expect(mockPtySpawn).toHaveBeenCalledTimes(2);
});
});

describe('validateCommand', () => {
Expand Down
13 changes: 13 additions & 0 deletions electron/ipc/register-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ const VALID_ARGS = {
coordinatorTaskId: TEST_COORDINATOR_ID,
projectId: 'proj-1',
projectRoot: '/absolute/project',
coordinatorBranch: 'task/coordinator-work',
worktreePath: '/absolute/worktree',
agentArgs: ['--flag', 'value'],
dockerContainerName: 'my-container',
Expand Down Expand Up @@ -356,6 +357,18 @@ describe('Layer 4 — StartMCPServer input validation', () => {
expect(copyFileSpy).not.toHaveBeenCalled();
});

it('rejects invalid coordinatorBranch', () => {
const writeFileSpy = vi.spyOn(fs, 'writeFileSync');
const copyFileSpy = vi.spyOn(fs, 'copyFileSync');

expect(() =>
validateStartMCPServerArgs({ ...VALID_ARGS, coordinatorBranch: 'bad branch' }),
).toThrow('coordinatorBranch');

expect(writeFileSpy).not.toHaveBeenCalled();
expect(copyFileSpy).not.toHaveBeenCalled();
});

it('rejects agentArgs containing a non-string element', () => {
const writeFileSpy = vi.spyOn(fs, 'writeFileSync');
const copyFileSpy = vi.spyOn(fs, 'copyFileSync');
Expand Down
40 changes: 38 additions & 2 deletions electron/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ export function validateStartMCPServerArgs(args: Record<string, unknown>): void
assertString(args.projectId, 'projectId');
validatePath(args.projectRoot, 'projectRoot');
if (args.worktreePath !== undefined) validatePath(args.worktreePath, 'worktreePath');
if (args.coordinatorBranch !== undefined) {
validateBranchName(args.coordinatorBranch, 'coordinatorBranch');
}
if (args.agentCommand !== undefined) assertString(args.agentCommand, 'agentCommand');
if (args.agentArgs !== undefined) assertStringArray(args.agentArgs, 'agentArgs');
assertOptionalBoolean(args.skipPermissions, 'skipPermissions');
Expand Down Expand Up @@ -1180,10 +1183,22 @@ export function registerAllHandlers(win: BrowserWindow): void {

ipcMain.handle(
IPC.MCP_CoordinatorRegistered,
(_e, args: { coordinatorTaskId: string; projectId: string; worktreePath?: string }) => {
(
_e,
args: {
coordinatorTaskId: string;
projectId: string;
coordinatorBranch?: string;
worktreePath?: string;
},
) => {
assertString(args.coordinatorTaskId, 'coordinatorTaskId');
assertString(args.projectId, 'projectId');
if (args.coordinatorBranch !== undefined) {
validateBranchName(args.coordinatorBranch, 'coordinatorBranch');
}
coordinator?.registerCoordinator(args.coordinatorTaskId, args.projectId, {
branchName: args.coordinatorBranch,
worktreePath: args.worktreePath,
});
},
Expand Down Expand Up @@ -1254,6 +1269,11 @@ export function registerAllHandlers(win: BrowserWindow): void {
},
);

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

ipcMain.handle(
IPC.MCP_CoordinatedTaskClosed,
(_e, args: { taskId: string; coordinatorTaskId: string }) => {
Expand All @@ -1280,7 +1300,13 @@ export function registerAllHandlers(win: BrowserWindow): void {
agentId?: string;
signalDoneAt?: string;
signalDoneConsumed?: boolean;
verification?: import('../mcp/types.js').SubtaskVerification;
landingState?: import('../mcp/types.js').LandingState;
landingReason?: string;
landingSummary?: string;
landedMetadata?: import('../mcp/types.js').LandedMetadata;
mcpConfigPath?: string;
agentCommand?: string;
preambleFileExistedBefore?: boolean;
},
) => {
Expand All @@ -1296,7 +1322,8 @@ export function registerAllHandlers(win: BrowserWindow): void {
assertString(args.coordinatorTaskId, 'coordinatorTaskId');
validateUUID(args.coordinatorTaskId, 'coordinatorTaskId');
if (!coordinator) throw new Error('coordinator mode not initialized');
coordinator.hydrateTask({
if (args.agentCommand !== undefined) assertString(args.agentCommand, 'agentCommand');
const result = coordinator.hydrateTask({
id: args.id,
name: args.name,
projectId: args.projectId,
Expand All @@ -1309,13 +1336,20 @@ export function registerAllHandlers(win: BrowserWindow): void {
controlledBy: args.controlledBy,
signalDoneAt: args.signalDoneAt,
signalDoneConsumed: args.signalDoneConsumed,
verification: args.verification,
landingState: args.landingState,
landingReason: args.landingReason,
landingSummary: args.landingSummary,
landedMetadata: args.landedMetadata,
mcpConfigPath: args.mcpConfigPath,
agentCommand: args.agentCommand,
preambleFileExistedBefore: args.preambleFileExistedBefore,
});
// Signal to renderer that MCP hydration is complete — gates TerminalView auto-spawn.
if (!win.isDestroyed()) {
win.webContents.send(IPC.MCP_TaskHydrated, { taskId: args.id });
}
return result;
},
);
}
Expand Down Expand Up @@ -1361,6 +1395,7 @@ export function registerAllHandlers(win: BrowserWindow): void {
coordinatorTaskId: string;
projectId: string;
projectRoot: string;
coordinatorBranch?: string;
worktreePath?: string;
skipPermissions?: boolean;
propagateSkipPermissions?: boolean;
Expand Down Expand Up @@ -1405,6 +1440,7 @@ export function registerAllHandlers(win: BrowserWindow): void {
// so create_task / list_tasks know about it. Idempotent — safe to call on restore.
coordinator.setDefaultProject(args.projectId, args.projectRoot, args.coordinatorTaskId);
coordinator.registerCoordinator(args.coordinatorTaskId, args.projectId, {
branchName: args.coordinatorBranch,
worktreePath: args.worktreePath,
skipPermissions: Boolean(args.skipPermissions && args.propagateSkipPermissions),
});
Expand Down
21 changes: 18 additions & 3 deletions electron/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
ApiDiffResult,
ApiMergeResult,
ApiReviewAndMergeResult,
ApiLandSelfResult,
LandSelfInput,
WaitForSignalDoneResult,
} from './types.js';

Expand Down Expand Up @@ -106,19 +108,32 @@ export class MCPClient {
}

async signalDone(taskId: string): Promise<void> {
const url = `${this.baseUrl}/api/tasks/${encodeURIComponent(taskId)}/done`;
await this.taskOwnerRequest('POST', `/api/tasks/${encodeURIComponent(taskId)}/done`, {});
}

async landSelf(taskId: string, input: LandSelfInput): Promise<ApiLandSelfResult> {
return this.taskOwnerRequest<ApiLandSelfResult>(
'POST',
`/api/tasks/${encodeURIComponent(taskId)}/land`,
input,
);
}

private async taskOwnerRequest<T>(method: string, path: string, body: unknown): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
};
// Per-task done token is sent as X-Done-Token so the server can verify task ownership
// without needing per-task bearer token classification.
if (this.doneToken) headers['X-Done-Token'] = this.doneToken;
const res = await fetch(url, { method: 'POST', headers, body: '{}' });
const res = await fetch(url, { method, headers, body: JSON.stringify(body) });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`API POST /api/tasks/.../done failed (${res.status}): ${text}`);
throw new Error(`API ${method} ${path} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}

async waitForSignalDone(
Expand Down
Loading
Loading