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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 'lts/*'
node-version: '22'
cache: npm

- run: npm ci
Expand Down
20 changes: 10 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ jobs:
outputs:
release_id: ${{ steps.create.outputs.id }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Create draft release
id: create
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
draft: true
generate_release_notes: true
Expand All @@ -27,18 +27,18 @@ jobs:
needs: create-release
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 'lts/*'
node-version: '22'
cache: npm

- run: npm ci
- run: npm run build -- --publish never

- name: Upload artifacts
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
tag_name: ${{ github.ref_name }}
files: |
Expand All @@ -50,11 +50,11 @@ jobs:
needs: create-release
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 'lts/*'
node-version: '22'
cache: npm

- name: Import code signing certificate
Expand Down Expand Up @@ -98,7 +98,7 @@ jobs:
run: npm run build -- --universal --publish never

- name: Upload artifacts
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
tag_name: ${{ github.ref_name }}
files: release/*.dmg
Expand Down
21 changes: 21 additions & 0 deletions .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/sh
commit_msg=$(cat "$1")

# Allow merge commits, revert commits, and fixup/squash commits
if echo "$commit_msg" | grep -qE "^(Merge|Revert|fixup!|squash!)"; then
exit 0
fi

# Enforce conventional commit format: type(scope): message
if ! echo "$commit_msg" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,}"; then
echo ""
echo "Invalid commit message format."
echo "Expected: type(scope): description"
echo "Examples:"
echo " feat(coordinator): add MCP status indicator"
echo " fix(terminal): restore PTY preservation on reload"
echo " chore: update dependencies"
echo ""
echo "Valid types: feat fix docs style refactor perf test build ci chore revert"
exit 1
fi
15 changes: 15 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
#!/bin/sh
npx lint-staged
npm run check

# Verify package-lock.json is committed and in sync with package.json
if git diff --cached --name-only | grep -q "package\.json$"; then
if ! git diff --cached --name-only | grep -q "package-lock\.json$"; then
echo "Error: package.json changed without updating package-lock.json"
echo "Run 'npm install' to update the lockfile"
exit 1
fi
fi

# Ensure package-lock.json is not gitignored (supply chain: lockfile must be tracked)
if git check-ignore -q package-lock.json 2>/dev/null; then
echo "Error: package-lock.json must not be gitignored — it provides integrity hashes"
exit 1
fi
9 changes: 9 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -1,2 +1,11 @@
#!/bin/sh
npm run check

echo "Running tests before push..."
npm test
if [ $? -ne 0 ]; then
echo ""
echo "Tests failed. Fix failing tests before pushing."
echo "Run 'npm test' to see details."
exit 1
fi
7 changes: 7 additions & 0 deletions electron/ipc/agents.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import path from 'path';

const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -81,6 +82,12 @@ let cachedAgents: AgentDef[] | null = null;
let cacheTime = 0;
const AGENT_CACHE_TTL = 30_000;

export function getSkipPermissionsArgs(command: string): string[] {
const base = path.basename(command);
const agent = DEFAULT_AGENTS.find((a) => a.command === base || a.command === command);
return agent ? agent.skip_permissions_args : [];
}

export async function listAgents(): Promise<AgentDef[]> {
const now = Date.now();
if (cachedAgents && now - cacheTime < AGENT_CACHE_TTL) {
Expand Down
27 changes: 27 additions & 0 deletions electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,31 @@ export enum IPC {

// Logging
LogFromRenderer = 'log_from_renderer',

// MCP / Coordinating agent
SetCoordinatorModeEnabled = 'set_coordinator_mode_enabled',
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_CoordinatorNotificationCleared = 'mcp_coordinator_notification_cleared',
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_CoordinatorNotificationDropAck = 'mcp_coordinator_notification_drop_ack',
MCP_CoordinatedTaskPromptDelivered = 'mcp_coordinated_task_prompt_delivered',
MCP_CoordinatorRestageAfterUserSend = 'mcp_coordinator_restage_after_user_send',
MCP_HydrateCoordinatedTask = 'mcp_hydrate_coordinated_task',
MCP_TaskHydrated = 'mcp_task_hydrated',
MCP_StaleUrlWarning = 'mcp_stale_url_warning',
MCP_CoordinatedTaskClosed = 'mcp_coordinated_task_closed',
MCP_TaskCleanupFailed = 'mcp_task_cleanup_failed',
}
172 changes: 172 additions & 0 deletions electron/ipc/docker-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Layer 1 — Docker coordinator config (pure, no Docker required)
*
* Fast unit tests for the pure functions that generate MCP config for Docker coordinators.
* No Docker, no network, no filesystem writes.
*/

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

// ── MCP server URL ─────────────────────────────────────────────────────────────

describe('getMCPRemoteServerUrl — host resolution', () => {
it('uses host.docker.internal on macOS Docker Desktop', () => {
expect(getMCPRemoteServerUrl(3001, 'my-container', 'darwin')).toBe(
'http://host.docker.internal:3001',
);
});

it('uses 127.0.0.1 on Linux (--network host makes localhost IS the host)', () => {
// --add-host=host.docker.internal:host-gateway is incompatible with --network host on Linux.
// With --network host the container shares the host network stack, so 127.0.0.1 IS the host.
expect(getMCPRemoteServerUrl(3001, 'my-container', 'linux')).toBe('http://127.0.0.1:3001');
});

it('uses 127.0.0.1 when no container name (non-Docker)', () => {
expect(getMCPRemoteServerUrl(3001, undefined)).toBe('http://127.0.0.1:3001');
});

it('uses 127.0.0.1 when container name is empty string', () => {
expect(getMCPRemoteServerUrl(3001, '')).toBe('http://127.0.0.1:3001');
});
});

// ── .mcp.json placement ────────────────────────────────────────────────────────

describe('selectMcpJsonDir — .mcp.json placement', () => {
it('places .mcp.json in worktreePath when provided', () => {
expect(selectMcpJsonDir('/worktrees/coord-abc', '/project')).toBe('/worktrees/coord-abc');
});

it('falls back to projectRoot when worktreePath is undefined', () => {
expect(selectMcpJsonDir(undefined, '/project')).toBe('/project');
});

it('worktreePath wins over projectRoot (Docker: container only mounts worktree)', () => {
const worktreePath = '/Users/alice/repo/.worktrees/task/coord-abc123';
const projectRoot = '/Users/alice/repo';
const dir = selectMcpJsonDir(worktreePath, projectRoot);
// .mcp.json must be inside the volume-mounted worktree, not the projectRoot (not mounted)
expect(dir).toBe(worktreePath);
expect(dir).not.toBe(projectRoot);
});
});

// ── copied mcp-server.cjs path ─────────────────────────────────────────────────

describe('getDockerMcpServerDestPath — copied mcp-server.cjs location', () => {
it('places mcp-server.cjs in worktree .parallel-code dir', () => {
const dest = getDockerMcpServerDestPath('/worktrees/coord', '/project');
expect(dest).toBe('/worktrees/coord/.parallel-code/mcp-server.cjs');
});

it('falls back to projectRoot when worktreePath is undefined', () => {
const dest = getDockerMcpServerDestPath(undefined, '/project');
expect(dest).toBe('/project/.parallel-code/mcp-server.cjs');
});

it('dest is under the mounted worktree, not the unmounted projectRoot', () => {
const worktreePath = '/home/user/repo/.worktrees/task/coord-abc123';
const projectRoot = '/home/user/repo';
const dest = getDockerMcpServerDestPath(worktreePath, projectRoot);
// The container mounts worktreePath (not projectRoot), so the script must live there
expect(dest.startsWith(worktreePath)).toBe(true);
expect(dest.startsWith(projectRoot + '/.parallel-code')).toBe(false);
});

it('filename is always mcp-server.cjs', () => {
const dest = getDockerMcpServerDestPath('/worktrees/coord', '/project');
expect(dest.endsWith('/mcp-server.cjs')).toBe(true);
});
});

// ── .mcp.json config content ───────────────────────────────────────────────────

describe('buildCoordinatorMCPConfig — config content', () => {
const baseOpts = {
mcpServerPath: '/worktrees/coord/.parallel-code/mcp-server.cjs',
serverUrl: 'http://host.docker.internal:3001',
token: 'test-token-abc',
coordinatorTaskId: 'coord-task-1',
};

it('has type:stdio and command:node', () => {
const cfg = buildCoordinatorMCPConfig(baseOpts);
const server = cfg.mcpServers['parallel-code'];
expect(server.type).toBe('stdio');
expect(server.command).toBe('node');
});

it('args[0] is the mcp-server.cjs path (the copied worktree path, not host path)', () => {
const cfg = buildCoordinatorMCPConfig(baseOpts);
expect(cfg.mcpServers['parallel-code'].args[0]).toBe(baseOpts.mcpServerPath);
});

it('args contain --url pointing to host.docker.internal', () => {
const cfg = buildCoordinatorMCPConfig(baseOpts);
const args = cfg.mcpServers['parallel-code'].args;
const urlIdx = args.indexOf('--url');
expect(urlIdx).toBeGreaterThan(0);
expect(args[urlIdx + 1]).toBe('http://host.docker.internal:3001');
});

it('token is passed via env var, not args', () => {
const cfg = buildCoordinatorMCPConfig(baseOpts);
const args = cfg.mcpServers['parallel-code'].args;
expect(args).not.toContain('--token');
expect(cfg.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe(baseOpts.token);
});

it('args contain --coordinator-id', () => {
const cfg = buildCoordinatorMCPConfig(baseOpts);
const args = cfg.mcpServers['parallel-code'].args;
const coordIdx = args.indexOf('--coordinator-id');
expect(coordIdx).toBeGreaterThan(0);
expect(args[coordIdx + 1]).toBe(baseOpts.coordinatorTaskId);
});

it('omits --skip-permissions by default', () => {
const cfg = buildCoordinatorMCPConfig(baseOpts);
expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions');
});

it('adds --skip-permissions when both flags are true', () => {
const cfg = buildCoordinatorMCPConfig({
...baseOpts,
skipPermissions: true,
propagateSkipPermissions: true,
});
expect(cfg.mcpServers['parallel-code'].args).toContain('--skip-permissions');
});

it('does NOT add --skip-permissions when propagateSkipPermissions is false', () => {
const cfg = buildCoordinatorMCPConfig({
...baseOpts,
skipPermissions: true,
propagateSkipPermissions: false,
});
expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions');
});

it('does NOT add --skip-permissions when skipPermissions is false', () => {
const cfg = buildCoordinatorMCPConfig({
...baseOpts,
skipPermissions: false,
propagateSkipPermissions: true,
});
expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions');
});

it('JSON-serialised output is valid JSON with the parallel-code key', () => {
const cfg = buildCoordinatorMCPConfig(baseOpts);
const json = JSON.stringify(cfg, null, 2);
const parsed = JSON.parse(json) as typeof cfg;
expect(parsed.mcpServers['parallel-code']).toBeDefined();
});
});
Loading
Loading